Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9483d4a
eth/protocols/wit, consensus/bor, eth: WIT2 — BP-signed witness annou…
lucca30 Apr 30, 2026
6aef74a
wit2: address lint — drop unused symbols, run goimports
lucca30 Apr 30, 2026
4fdc09c
wit2: address review — reuse encoded body, skip WIT1 encode, fix peer…
lucca30 Apr 30, 2026
8cc7f8e
wit2: address adversarial-review safety findings (TDD)
lucca30 May 5, 2026
9b6adb9
wit2: deferred-announce queue closes the cosend race
lucca30 May 5, 2026
46ebbbc
eth/wit: cap GetWitness page count (F-1) and per-peer deferred-announ…
lucca30 Jun 9, 2026
fe20334
wit2: push witness body to waiting peers + back off empty fetches
lucca30 Jun 9, 2026
44d9458
eth: pin multi-page witness reassembly dup-page gap with a documentin…
lucca30 Jun 9, 2026
858e218
wit2: size-gate waiter push; verify-or-drop witness broadcasts
lucca30 Jun 9, 2026
da2e099
wit2: accept waiter-pushed witnesses for deferred-announce tip blocks
lucca30 Jun 9, 2026
eb0f462
wit2: fall back to the deferred-announce relayer for witness fetch
lucca30 Jun 9, 2026
8c16b39
wit2: only the block producer may sign witness announcements
lucca30 Jun 9, 2026
c7b4be0
wit2: address CI quality gates — coverage, duplication, diffguard
lucca30 Jun 10, 2026
c9d4756
wit2: close remaining CI gaps — patch coverage, diffguard complexity …
lucca30 Jun 10, 2026
13d0536
Merge remote-tracking branch 'origin/develop' into lmartins/wit2-sign…
lucca30 Jun 25, 2026
a5dfbe5
eth: address review findings on WIT2 signed-announce path
lucca30 Jun 30, 2026
6f61ae8
wit2: fix peer-tracker leak, quarantine leak, jail-on-strike, strike …
lucca30 Jun 30, 2026
2cb205c
wit2: release per-peer waiter state on disconnect and pre-import body…
lucca30 Jun 30, 2026
8e0c994
wit2: skip disconnected relayers when choosing a deferred-announce fe…
lucca30 Jul 1, 2026
90d07e1
eth: quiet diffguard quality gate (complexity + file size), no behavi…
lucca30 Jul 2, 2026
1f7d0dc
eth/fetcher: strike a witness server at most once per (peer, block) o…
lucca30 Jul 2, 2026
b8b4497
Merge remote-tracking branch 'origin/develop' into lmartins/wit2-sign…
lucca30 Jul 3, 2026
473cba8
eth: extract handleGetWitness helpers to restore diffguard size gate
lucca30 Jul 3, 2026
28298ee
eth: cover loadWitnessPageData truncation paths to restore patch cove…
lucca30 Jul 3, 2026
e33bc26
Merge remote-tracking branch 'origin/develop' into lmartins/wit2-sign…
lucca30 Jul 20, 2026
fb2eb4c
eth, core/stateless: add tests for WIT2 mismatch/announce/chunk logic
lucca30 Jul 20, 2026
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
11 changes: 6 additions & 5 deletions accounts/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,12 @@ type Account struct {
}

const (
MimetypeDataWithValidator = "data/validator"
MimetypeTypedData = "data/typed"
MimetypeClique = "application/x-clique-header"
MimetypeBor = "application/x-bor-header"
MimetypeTextPlain = "text/plain"
MimetypeDataWithValidator = "data/validator"
MimetypeTypedData = "data/typed"
MimetypeClique = "application/x-clique-header"
MimetypeBor = "application/x-bor-header"
MimetypeBorWitnessAnnounce = "application/x-bor-wit2-announce"
MimetypeTextPlain = "text/plain"
)

// Wallet represents a software or hardware wallet that might contain one or more
Expand Down
1 change: 1 addition & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ comment:
ignore:
- "consensus/bor/genesis_contract_mock.go"
- "consensus/bor/span_mock.go"
- "eth/peer_mock.go"

36 changes: 36 additions & 0 deletions consensus/bor/bor.go
Original file line number Diff line number Diff line change
Expand Up @@ -1555,6 +1555,42 @@ func Sign(signFn SignerFn, signer common.Address, header *types.Header, c *param
return nil
}

// SignBytes signs the supplied preimage bytes under a context-specific
// mimetype using the engine's currently authorized signer. The mimetype is the
// domain tag the underlying signer (clef, keystore) sees, so callers MUST pass
// a context-specific value (e.g. accounts.MimetypeBorWitnessAnnounce) and
// never reuse accounts.MimetypeBor outside of header sealing — that would let
// a signature produced here be replayed as a block-seal signature on any
// header BorRLP that hashes to the same digest.
//
// Callers pass the unhashed preimage; the wallet's SignData implementation
// applies keccak256 once before signing. Verifiers must independently hash
// the same preimage and ecrecover against the resulting digest.
func (c *Bor) SignBytes(mimetype string, digest []byte) (signer common.Address, sig []byte, err error) {
if mimetype == "" || mimetype == accounts.MimetypeBor {
return common.Address{}, nil, errors.New("bor: SignBytes requires a non-empty, non-header mimetype")
}
current := c.authorizedSigner.Load()
if current == nil || current.signer == (common.Address{}) {
return common.Address{}, nil, errors.New("bor: no authorized signer configured")
}
sig, err = current.signFn(accounts.Account{Address: current.signer}, mimetype, digest)
if err != nil {
return common.Address{}, nil, err
}
return current.signer, sig, nil
}

// CurrentSigner returns the address of the currently authorized signer, or
// the zero address if none has been configured.
func (c *Bor) CurrentSigner() common.Address {
current := c.authorizedSigner.Load()
if current == nil {
return common.Address{}
}
return current.signer
}

// CalcDifficulty is the difficulty adjustment algorithm. It returns the difficulty
// that a new block should have based on the previous blocks in the chain and the
// current signer.
Expand Down
121 changes: 121 additions & 0 deletions consensus/bor/signbytes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package bor

import (
"bytes"
"errors"
"strings"
"testing"

"github.com/ethereum/go-ethereum/accounts"
"github.com/ethereum/go-ethereum/common"
)

// TestSignBytesForwardsMimetype is the regression for the wit2 announce
// signing path's external-signer compatibility: bor.SignBytes must hand the
// caller-supplied mimetype to the configured signer untouched. Operators
// configuring Clef whitelist a specific string ("application/x-bor-wit2-
// announce"); if SignBytes ever rewrote, lower-cased, or stripped that, the
// signer would either reject the request or sign under a different domain.
//
// The test captures the (mimetype, payload) the wallet sees and asserts both
// match exactly what the caller passed.
func TestSignBytesForwardsMimetype(t *testing.T) {
bor := &Bor{}
addr := common.HexToAddress("0x1234")

var (
gotMimetype string
gotPayload []byte
)
bor.Authorize(addr, func(_ accounts.Account, mimetype string, data []byte) ([]byte, error) {
gotMimetype = mimetype
gotPayload = append([]byte(nil), data...)
return make([]byte, 65), nil
})

preimage := []byte("wit2-announce-preimage")
signer, sig, err := bor.SignBytes(accounts.MimetypeBorWitnessAnnounce, preimage)
if err != nil {
t.Fatalf("SignBytes: %v", err)
}
if signer != addr {
t.Fatalf("signer addr mismatch: got %s want %s", signer, addr)
}
if len(sig) != 65 {
t.Fatalf("expected 65-byte signature, got %d", len(sig))
}
if gotMimetype != accounts.MimetypeBorWitnessAnnounce {
t.Fatalf("mimetype not forwarded literally: got %q want %q",
gotMimetype, accounts.MimetypeBorWitnessAnnounce)
}
if !bytes.Equal(gotPayload, preimage) {
t.Fatalf("payload not forwarded literally: got %x want %x", gotPayload, preimage)
}
}

// TestSignBytesRejectsHeaderMimetype guards against accidental cross-context
// reuse: callers must never pass MimetypeBor (header sealing) into SignBytes,
// since that would let an announce signature replay as a block-seal.
func TestSignBytesRejectsHeaderMimetype(t *testing.T) {
bor := &Bor{}
bor.Authorize(common.HexToAddress("0x1234"), func(accounts.Account, string, []byte) ([]byte, error) {
t.Fatal("signFn must not be reached for rejected mimetype")
return nil, nil
})

if _, _, err := bor.SignBytes("", []byte{0x01}); err == nil {
t.Fatal("empty mimetype must be rejected")
}
if _, _, err := bor.SignBytes(accounts.MimetypeBor, []byte{0x01}); err == nil {
t.Fatal("MimetypeBor must be rejected to prevent header-seal replay")
}
}

// TestSignBytesWithoutAuthorizedSigner covers the not-a-validator paths: a
// node that never called Authorize (or authorized the zero address) must
// refuse to sign rather than emit a signature under a zero identity.
func TestSignBytesWithoutAuthorizedSigner(t *testing.T) {
bor := &Bor{}
if _, _, err := bor.SignBytes(accounts.MimetypeBorWitnessAnnounce, []byte{0x01}); err == nil {
t.Fatal("SignBytes must fail with no authorized signer")
}

bor.Authorize(common.Address{}, func(accounts.Account, string, []byte) ([]byte, error) {
t.Fatal("signFn must not be reached for a zero-address signer")
return nil, nil
})
if _, _, err := bor.SignBytes(accounts.MimetypeBorWitnessAnnounce, []byte{0x01}); err == nil {
t.Fatal("SignBytes must fail for a zero-address signer")
}
}

// TestSignBytesPropagatesSignFnError pins that wallet/clef failures surface to
// the caller instead of returning a bogus (signer, nil-sig) pair.
func TestSignBytesPropagatesSignFnError(t *testing.T) {
bor := &Bor{}
bor.Authorize(common.HexToAddress("0x1234"), func(accounts.Account, string, []byte) ([]byte, error) {
return nil, errors.New("wallet locked")
})

_, _, err := bor.SignBytes(accounts.MimetypeBorWitnessAnnounce, []byte{0x01})
if err == nil || !strings.Contains(err.Error(), "wallet locked") {
t.Fatalf("expected wallet error to propagate, got %v", err)
}
}

// TestCurrentSigner covers both states of the authorized-signer lookup used by
// the wit2 announce path to decide whether this node may sign announcements.
func TestCurrentSigner(t *testing.T) {
bor := &Bor{}
if got := bor.CurrentSigner(); got != (common.Address{}) {
t.Fatalf("expected zero address before Authorize, got %s", got)
}

addr := common.HexToAddress("0x5678")
bor.Authorize(addr, func(accounts.Account, string, []byte) ([]byte, error) {
return make([]byte, 65), nil
})
if got := bor.CurrentSigner(); got != addr {
t.Fatalf("CurrentSigner: got %s want %s", got, addr)
}
}
26 changes: 19 additions & 7 deletions core/stateless/encoding.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
package stateless

import (
"bytes"
"io"
"sort"

"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/core/types"
Expand Down Expand Up @@ -84,19 +86,29 @@ func (w *Witness) fromExtWitness(ext *ExtWitness) error {
// EncodeRLP serializes a witness as RLP using the canonical BorWitness 3-field
// format. Only state trie nodes are encoded; contract bytecodes are not
// included in the wire format.
//
// State entries are sorted lexicographically before encoding so the output is
// byte-identical for any two witnesses with the same logical contents. Without
// this, Go's randomized map iteration would produce different bytes per call,
// breaking any code that hashes the encoded witness for content addressing —
// notably the WIT2 BP-signed witness hash, which is computed by both producer
// and verifiers and must match exactly.
func (w *Witness) EncodeRLP(wr io.Writer) error {
w.lock.RLock()
defer w.lock.RUnlock()

bw := &BorWitness{
Context: w.context,
Headers: w.Headers,
State: make([][]byte, 0, len(w.State)),
}
state := make([][]byte, 0, len(w.State))
for node := range w.State {
bw.State = append(bw.State, []byte(node))
state = append(state, []byte(node))
}
return rlp.Encode(wr, bw)
sort.Slice(state, func(i, j int) bool {
return bytes.Compare(state[i], state[j]) < 0
})
return rlp.Encode(wr, &BorWitness{
Context: w.context,
Headers: w.Headers,
State: state,
})
}

// DecodeRLP decodes a witness from RLP. It first attempts the canonical
Expand Down
59 changes: 59 additions & 0 deletions core/stateless/encoding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,62 @@ func TestRoundtrip_BorWitnessFormat(t *testing.T) {
t.Errorf("Codes should be empty after BorWitness roundtrip, got %d", len(decoded.Codes))
}
}

// TestEncodeRLP_DeterministicAcrossInsertionOrder is the regression test for
// the WIT2 byte-blame model. State entries arrive via a Go map, whose
// iteration order is randomised, so without sorting in EncodeRLP two
// witnesses with identical logical content would encode to different bytes
// and hash differently. Receivers verifying response bytes against the BP-
// signed witness hash would falsely drop honest peers.
func TestEncodeRLP_DeterministicAcrossInsertionOrder(t *testing.T) {
const N = 64
nodes := make([][]byte, N)
for i := 0; i < N; i++ {
nodes[i] = []byte{byte(i), byte(i ^ 0x5a), byte(i ^ 0xa5)}
}

makeWitness := func(insertionOrder []int) *Witness {
w := &Witness{
Headers: []*types.Header{{Number: big.NewInt(1)}},
Codes: make(map[string]struct{}),
State: make(map[string]struct{}, len(insertionOrder)),
}
w.context = &types.Header{Number: big.NewInt(2)}
for _, i := range insertionOrder {
w.State[string(nodes[i])] = struct{}{}
}
return w
}

encode := func(w *Witness) []byte {
raw, err := rlp.EncodeToBytes(w)
if err != nil {
t.Fatalf("encode: %v", err)
}
return raw
}

forward := make([]int, N)
for i := range forward {
forward[i] = i
}
reverse := make([]int, N)
for i := range reverse {
reverse[i] = N - 1 - i
}

wForward := makeWitness(forward)
wReverse := makeWitness(reverse)
if got, want := encode(wForward), encode(wReverse); string(got) != string(want) {
t.Fatalf("EncodeRLP must be deterministic across map insertion orders; got divergent bytes (%d vs %d)", len(got), len(want))
}

// Re-encoding the same witness multiple times must also yield identical
// bytes, even though Go map iteration is fresh each call.
first := encode(wForward)
for i := 0; i < 5; i++ {
if string(encode(wForward)) != string(first) {
t.Fatalf("repeat encode call %d differs from first", i)
}
}
}
Loading
Loading