Skip to content
Merged
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
73 changes: 73 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Go library (`github.com/ssvlabs/dkg-spec`) defining the specification for SSV's Distributed Key Generation (DKG) protocol. Operators use BLS threshold cryptography to generate shared validator keys for Ethereum — no single operator holds the full private key.

## Build & Test Commands

```bash
# Generate SSZ encoding (types_encoding.go) and EIP-1271 bindings
# Requires: abigen (geth tools) for eip1271 contract generation
go generate ./...

# Build
go build -v ./...

# Run all tests (tests live in testing/ package, not ./...)
go test ./testing/ ./crypto/

# Run a single test
go test ./testing/ -run TestValidateResults
go test ./crypto/ -run TestVerifyDepositData
```

**Important**: `go test` at root runs nothing useful. Tests are in `testing/` and `crypto/` packages.

## Architecture

### Three DKG Operations

All operations follow the same pattern: **initiator** sends a request, **operators** execute the ceremony, each operator produces a `Result` containing a `SignedProof`.

1. **Init** (`init.go`, `operator.go:Init`) — Fresh DKG ceremony creating new BLS key shares
2. **Reshare** (`reshare.go`, `operator.go:Reshare`) — Redistribute shares to a new operator set. Requires owner ECDSA/EIP-1271 signature and proofs from the previous ceremony
3. **Resign** (`resign.go`, `operator.go:Resign`) — Re-sign with corrected nonces without generating new keys. Also requires owner signature

### Key Data Flow

`Init/Reshare/Resign` message → operator validates → DKG ceremony (stubbed in spec) → `BuildResult()` → `Result` containing:
- `DepositPartialSignature` — BLS partial sig over ETH2 deposit data
- `OwnerNoncePartialSignature` — BLS partial sig over `owner:nonce` hash
- `SignedProof` — RSA-signed proof linking validator pubkey, encrypted share, share pubkey, and owner

`ValidateResults()` in `result.go` is the main verification entry point: recovers the master public key from shares, reconstructs master signatures, and verifies deposit data against the Ethereum network fork.

### Cryptography Layers (`crypto/`)

- **BLS** (`bls.go`) — Threshold key recovery and partial signature verification using `herumi/bls-eth-go-binary`. Must call `InitBLS()` before use.
- **RSA** (`rsa.go`) — Operator key pairs for encrypting shares and signing proofs (PSS signatures, PKCS1v15 encryption). Operator public keys are base64-encoded PEM.
- **Beacon** (`beacon.go`) — ETH2 deposit data computation and verification. Network determined by fork version bytes. Withdrawal credentials must be 32 bytes with a valid prefix: 0x01 (ETH1) or 0x02 (compounding). Use `WithdrawalCredentials(prefix, addr)` to construct them from a prefix byte and a 20-byte address.
- **Owner Signature** (`signature.go`) — Verifies owner authorization via either EOA (ECDSA with EIP-155 chain ID support) or smart contract wallet (EIP-1271).

### Encoding

All types use SSZ serialization. `types_encoding.go` is auto-generated from `types.go` via `fastssz/sszgen`. The `SSZMarshaller` interface is used for bulk message hashing (reshare/resign flows).

### Valid Cluster Sizes

Only 4 fixed operator counts are supported: **4** (t=3), **7** (t=5), **10** (t=7), **13** (t=9). Threshold follows `t = 2f+1` where `n = 3f+1`.

### Testing Structure

- `testing/` — Integration tests for init, result, reshare, resign validation
- `testing/fixtures/` — Deterministic test data: hardcoded RSA operator keys, BLS shares, pre-computed signatures and proofs for 4/7/10/13 operator configurations
- `testing/stubs/` — Mock `ETHClient` for EIP-1271/EOA signature verification
- `crypto/` — Unit tests for beacon deposit computation and BLS signature verification

### Generated Files (do not edit manually)

- `types_encoding.go` — SSZ marshaling from `go generate` on `types.go`
- `eip1271/eip1271.go` — Contract bindings from `abigen` on `eip1271/abi.abi`
38 changes: 28 additions & 10 deletions crypto/beacon.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,31 @@ import (

const (
// https://eips.ethereum.org/EIPS/eip-7251
MIN_ACTIVATION_BALANCE phase0.Gwei = 32000000000
MAX_EFFECTIVE_BALANCE phase0.Gwei = 2048000000000
ETH1WithdrawalPrefixByte = byte(1)
MIN_ACTIVATION_BALANCE phase0.Gwei = 32000000000
MAX_EFFECTIVE_BALANCE phase0.Gwei = 2048000000000
ETH1WithdrawalPrefix = byte(1)
CompoundingWithdrawalPrefix = byte(2)
)

func ETH1WithdrawalCredentials(withdrawalAddr []byte) []byte {
withdrawalCredentials := make([]byte, 32)
copy(withdrawalCredentials[:1], []byte{ETH1WithdrawalPrefixByte})
// withdrawalCredentials[1:12] == b'\x00' * 11 // this is not needed since cells are zeroed anyway
copy(withdrawalCredentials[12:], withdrawalAddr)
return withdrawalCredentials
// WithdrawalCredentials constructs 32-byte withdrawal credentials from a prefix byte and a 20-byte address.
func WithdrawalCredentials(prefix byte, withdrawalAddr [20]byte) []byte {
creds := make([]byte, 32)
creds[0] = prefix
copy(creds[12:], withdrawalAddr[:])
return creds
}

// ValidateWithdrawalCredentials checks that credentials are exactly 32 bytes with a valid prefix (0x01 or 0x02).
// Bytes [1:12] (zero padding) are not enforced — the Ethereum beacon chain accepts any padding,
// and WithdrawalCredentials() always zero-pads.
func ValidateWithdrawalCredentials(creds []byte) error {
if len(creds) != 32 {
return fmt.Errorf("withdrawal credentials must be 32 bytes, got %d", len(creds))
}
if creds[0] != ETH1WithdrawalPrefix && creds[0] != CompoundingWithdrawalPrefix {
return fmt.Errorf("invalid withdrawal credential prefix: 0x%02x", creds[0])
}
return nil
}

func ComputeDepositMessageSigningRoot(network core.Network, message *phase0.DepositMessage) (phase0.Root, error) {
Expand Down Expand Up @@ -91,8 +105,12 @@ func DepositDataRootForFork(
if err != nil {
return phase0.Root{}, err
}
if err := ValidateWithdrawalCredentials(withdrawalCredentials); err != nil {
return phase0.Root{}, err
}
return ComputeDepositMessageSigningRoot(network, &phase0.DepositMessage{
PublicKey: phase0.BLSPubKey(validatorPK),
Amount: amount,
WithdrawalCredentials: ETH1WithdrawalCredentials(withdrawalCredentials)})
WithdrawalCredentials: withdrawalCredentials,
})
}
139 changes: 130 additions & 9 deletions crypto/beacon_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,38 @@ import (
"github.com/stretchr/testify/require"
)

func TestETH1WithdrawalCredentials(t *testing.T) {
t.Run("eth1 withdrawal cred from string", func(t *testing.T) {
func TestWithdrawalCredentials(t *testing.T) {
t.Run("0x01 from hex address", func(t *testing.T) {
eth1Address := common.HexToAddress("d999bc994e0274235b65ca72ec430b8de3eb7df9")
require.EqualValues(t, ETH1WithdrawalCredentials(eth1Address[:]), []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xd9, 0x99, 0xbc, 0x99, 0x4e, 0x2, 0x74, 0x23, 0x5b, 0x65, 0xca, 0x72, 0xec, 0x43, 0xb, 0x8d, 0xe3, 0xeb, 0x7d, 0xf9})
require.EqualValues(t, WithdrawalCredentials(ETH1WithdrawalPrefix, eth1Address), []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xd9, 0x99, 0xbc, 0x99, 0x4e, 0x2, 0x74, 0x23, 0x5b, 0x65, 0xca, 0x72, 0xec, 0x43, 0xb, 0x8d, 0xe3, 0xeb, 0x7d, 0xf9})
})

t.Run("eth1 withdrawal cred from bytes", func(t *testing.T) {
t.Run("0x01 from byte address", func(t *testing.T) {
eth1Address := common.Address{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}
require.EqualValues(t, ETH1WithdrawalCredentials(eth1Address[:]), []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20})
require.EqualValues(t, WithdrawalCredentials(ETH1WithdrawalPrefix, eth1Address), []byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20})
})

t.Run("0x02 compounding", func(t *testing.T) {
addr := common.Address{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}
creds := WithdrawalCredentials(CompoundingWithdrawalPrefix, addr)

require.Len(t, creds, 32)
require.Equal(t, byte(2), creds[0], "first byte should be 0x02")
for i := 1; i < 12; i++ {
require.Equal(t, byte(0), creds[i], "padding bytes should be zero")
}
require.Equal(t, addr[:], creds[12:], "address should be in bytes 12-31")
})
}

func TestComputeDepositMessageSigningRoot(t *testing.T) {
// Effective 20-byte address used across signing root tests.
addr := [20]byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8}

t.Run("mainnet", func(t *testing.T) {
r, err := ComputeDepositMessageSigningRoot(core.MainNetwork, &phase0.DepositMessage{
PublicKey: phase0.BLSPubKey([]byte{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}),
WithdrawalCredentials: ETH1WithdrawalCredentials([]byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}),
WithdrawalCredentials: WithdrawalCredentials(ETH1WithdrawalPrefix, addr),
Amount: 32000000000,
})
require.NoError(t, err)
Expand All @@ -36,7 +51,7 @@ func TestComputeDepositMessageSigningRoot(t *testing.T) {
t.Run("holesky", func(t *testing.T) {
r, err := ComputeDepositMessageSigningRoot(core.HoleskyNetwork, &phase0.DepositMessage{
PublicKey: phase0.BLSPubKey([]byte{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}),
WithdrawalCredentials: ETH1WithdrawalCredentials([]byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}),
WithdrawalCredentials: WithdrawalCredentials(ETH1WithdrawalPrefix, addr),
Amount: 32000000000,
})
require.NoError(t, err)
Expand All @@ -45,11 +60,13 @@ func TestComputeDepositMessageSigningRoot(t *testing.T) {
}

func TestDepositDataRootForFork(t *testing.T) {
addr := [20]byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8}

t.Run("mainnet", func(t *testing.T) {
r, err := DepositDataRootForFork(
phase0.Version{0, 0, 0, 0},
[]byte{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
[]byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20},
WithdrawalCredentials(ETH1WithdrawalPrefix, addr),
32000000000,
)
require.NoError(t, err)
Expand All @@ -60,14 +77,118 @@ func TestDepositDataRootForFork(t *testing.T) {
r, err := DepositDataRootForFork(
phase0.Version{0x01, 0x01, 0x70, 0x00},
[]byte{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
[]byte{1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20},
WithdrawalCredentials(ETH1WithdrawalPrefix, addr),
32000000000,
)
require.NoError(t, err)
require.EqualValues(t, r, phase0.Root{69, 0, 246, 46, 94, 170, 246, 64, 34, 97, 251, 181, 210, 250, 187, 64, 43, 220, 229, 196, 72, 92, 164, 213, 123, 170, 99, 7, 22, 67, 87, 55})
})
}

func TestValidateWithdrawalCredentials(t *testing.T) {
tests := []struct {
name string
input []byte
wantErr string
}{
{
name: "valid 32-byte 0x01",
input: WithdrawalCredentials(ETH1WithdrawalPrefix, [20]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}),
},
{
name: "valid 32-byte 0x02",
input: WithdrawalCredentials(CompoundingWithdrawalPrefix, [20]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}),
},
{
name: "non-zero padding accepted",
input: func() []byte {
c := WithdrawalCredentials(ETH1WithdrawalPrefix, [20]byte{})
c[5] = 0xFF
return c
}(),
},
{
name: "32-byte 0x00 prefix rejected",
input: make([]byte, 32),
wantErr: "invalid withdrawal credential prefix: 0x00",
},
{
name: "32-byte 0xFF prefix rejected",
input: append([]byte{0xFF}, make([]byte, 31)...),
wantErr: "invalid withdrawal credential prefix: 0xff",
},
{
name: "wrong length 20 bytes rejected",
input: make([]byte, 20),
wantErr: "withdrawal credentials must be 32 bytes, got 20",
},
{
name: "wrong length 15 bytes",
input: make([]byte, 15),
wantErr: "withdrawal credentials must be 32 bytes, got 15",
},
{
name: "wrong length 0 bytes",
input: []byte{},
wantErr: "withdrawal credentials must be 32 bytes, got 0",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateWithdrawalCredentials(tt.input)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
})
}
}

func TestDepositDataRootForFork_0x02(t *testing.T) {
t.Run("mainnet 0x02", func(t *testing.T) {
creds := WithdrawalCredentials(CompoundingWithdrawalPrefix, [20]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20})
_, err := DepositDataRootForFork(
phase0.Version{0, 0, 0, 0},
[]byte{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
creds,
32000000000,
)
require.NoError(t, err)
})
}

func TestVerifyDepositData_0x02(t *testing.T) {
InitBLS()
sk := &bls.SecretKey{}
require.NoError(t, sk.SetHexString("11e35da0958187d89cd6f7cc2b07a0a3f6225ad1e2b089d12e9b08f7f171c1c9"))

pk := phase0.BLSPubKey{}
copy(pk[:], sk.GetPublicKey().Serialize())

creds := WithdrawalCredentials(CompoundingWithdrawalPrefix, [20]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20})

r, err := ComputeDepositMessageSigningRoot(core.MainNetwork, &phase0.DepositMessage{
PublicKey: pk,
WithdrawalCredentials: creds,
Amount: 32000000000,
})
require.NoError(t, err)

sig := phase0.BLSSignature{}
copy(sig[:], sk.SignByte(r[:]).Serialize())

depositData := &phase0.DepositData{
PublicKey: pk,
WithdrawalCredentials: creds,
Amount: 32000000000,
Signature: sig,
}

require.NoError(t, VerifyDepositData(core.MainNetwork, depositData))
}

func TestVerifyDepositData(t *testing.T) {
t.Run("mainnet", func(t *testing.T) {
InitBLS()
Expand Down
3 changes: 3 additions & 0 deletions init.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ func ValidateInitMessage(init *Init) error {
if !ValidAmountSet(phase0.Gwei(init.Amount)) {
return fmt.Errorf("amount should be in range between 32 ETH and 2048 ETH")
}
if err := crypto.ValidateWithdrawalCredentials(init.WithdrawalCredentials); err != nil {
return fmt.Errorf("invalid withdrawal credentials: %w", err)
}
return nil
}

Expand Down
5 changes: 5 additions & 0 deletions reshare.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"sort"

"github.com/attestantio/go-eth2-client/spec/phase0"

"github.com/ssvlabs/dkg-spec/crypto"
)

// ValidateReshareMessage returns nil if re-share message is valid
Expand All @@ -22,6 +24,9 @@ func ValidateReshareMessage(
if !bytes.Equal(reshare.Owner[:], proof.Proof.Owner[:]) {
return fmt.Errorf("invalid owner address")
}
if err := crypto.ValidateWithdrawalCredentials(reshare.WithdrawalCredentials); err != nil {
return fmt.Errorf("invalid withdrawal credentials: %w", err)
}

if err := ValidateCeremonyProof(reshare.ValidatorPubKey, operator, *proof); err != nil {
return err
Expand Down
6 changes: 5 additions & 1 deletion resign.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"fmt"

"github.com/attestantio/go-eth2-client/spec/phase0"

"github.com/ssvlabs/dkg-spec/crypto"
)

// ValidateResignMessage returns nil if re-sign message is valid
Expand All @@ -15,9 +17,11 @@ func ValidateResignMessage(
if !ValidAmountSet(phase0.Gwei(resign.Amount)) {
return fmt.Errorf("amount should be in range between 32 ETH and 2048 ETH")
}
if err := crypto.ValidateWithdrawalCredentials(resign.WithdrawalCredentials); err != nil {
return fmt.Errorf("invalid withdrawal credentials: %w", err)
}
if err := ValidateCeremonyProof(resign.ValidatorPubKey, operator, *proof); err != nil {
return err
}

return nil
}
11 changes: 9 additions & 2 deletions result.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,13 @@ func ValidateResults(
if err != nil {
return nil, nil, nil, err
}
if err := crypto.ValidateWithdrawalCredentials(withdrawalCredentials); err != nil {
return nil, nil, nil, err
}
depositData := &phase0.DepositData{
PublicKey: phase0.BLSPubKey(validatorRecoveredPK.Serialize()),
Amount: phase0.Gwei(amount),
WithdrawalCredentials: crypto.ETH1WithdrawalCredentials(withdrawalCredentials),
WithdrawalCredentials: withdrawalCredentials,
Signature: phase0.BLSSignature(masterDepositSig.Serialize()),
}
err = crypto.VerifyDepositData(network, depositData)
Expand Down Expand Up @@ -292,10 +295,14 @@ func VerifyPartialDepositDataSignatures(
return err
}

if err := crypto.ValidateWithdrawalCredentials(withdrawalCredentials); err != nil {
return err
}
shareRoot, err := crypto.ComputeDepositMessageSigningRoot(network, &phase0.DepositMessage{
PublicKey: phase0.BLSPubKey(validatorPubKey),
Amount: amount,
WithdrawalCredentials: crypto.ETH1WithdrawalCredentials(withdrawalCredentials)})
WithdrawalCredentials: withdrawalCredentials,
})
if err != nil {
return fmt.Errorf("failed to compute deposit data root: %w", err)
}
Expand Down
Loading
Loading