From 7d7004b7a2896c55939b76e81653d4c4eec34005 Mon Sep 17 00:00:00 2001 From: Anna Shaleva Date: Wed, 17 Jul 2024 18:43:34 +0300 Subject: [PATCH] WIP: add testing infrastructure for Anti-MEV dBFT extension Add custom PreBlock and Block interfaces implementation, custom Commit and CommitAck, adjust testing logic. WIP, not finished, not buildable, but the idea can be traced. Continue testing infrastructure finalisation. Signed-off-by: Anna Shaleva --- check.go | 4 +- context.go | 10 +++ dbft.go | 6 ++ dbft_test.go | 115 ++++++++++++++++++++++++ internal/consensus/amev_block.go | 126 +++++++++++++++++++++++++++ internal/consensus/amev_commit.go | 44 ++++++++++ internal/consensus/amev_commitAck.go | 44 ++++++++++ internal/consensus/amev_preblock.go | 76 ++++++++++++++++ internal/consensus/constructors.go | 16 ++++ pre_block.go | 3 - 10 files changed, 439 insertions(+), 5 deletions(-) create mode 100644 internal/consensus/amev_block.go create mode 100644 internal/consensus/amev_commit.go create mode 100644 internal/consensus/amev_commitAck.go create mode 100644 internal/consensus/amev_preblock.go diff --git a/check.go b/check.go index 128ddd21..780ddf2e 100644 --- a/check.go +++ b/check.go @@ -63,11 +63,11 @@ func (d *DBFT[H]) checkCommit() { // signature bytes, as with usual dBFT. if d.isAntiMEVExtensionEnabled() { d.preBlock = d.CreatePreBlock() - hash := d.preBlock.Hash() + // hash := d.preBlock.Hash() // PreBlock Hash is needed only for informational purposes, it doesn't arry any sence and will be changed by CommitAck phase anyway. d.Logger.Info("processing PreBlock", zap.Uint32("height", d.BlockIndex), - zap.Stringer("preBlock hash", hash), + //zap.Stringer("preBlock hash", hash), zap.Int("tx_count", len(d.preBlock.Transactions()))) d.preBlockProcessed = true diff --git a/context.go b/context.go index 81308eb3..9969f017 100644 --- a/context.go +++ b/context.go @@ -210,6 +210,10 @@ func (c *Context[H]) MoreThanFNodesCommittedOrLost() bool { return c.CountCommitted()+c.CountFailed() > c.F() } +func (c *Context[H]) PreBlock() PreBlock[H] { + return c.preHeader // without transactions +} + func (c *Context[H]) reset(view byte, ts uint64) { c.MyIndex = -1 c.lastBlockTimestamp = ts @@ -298,6 +302,12 @@ func (c *Context[H]) CreateBlock() Block[H] { return nil } + // For anti-MEV extensions we don't need to initialize block's transactions + // since we already did this for PreBlock. + if c.isAntiMEVExtensionEnabled() { + return c.block + } + txx := make([]Transaction[H], len(c.TransactionHashes)) for i, h := range c.TransactionHashes { diff --git a/dbft.go b/dbft.go index de87c022..ce5b3813 100644 --- a/dbft.go +++ b/dbft.go @@ -716,3 +716,9 @@ func (d *DBFT[H]) extendTimer(count time.Duration) { func (d *DBFT[H]) Header() Block[H] { return d.header } + +// PreHeader returns current preHeader from context. May be nil in case if no +// preHeader is constructed yet. Do not change the resulting preHeader. +func (d *DBFT[H]) PreHeader() PreBlock[H] { + return d.preHeader +} diff --git a/dbft_test.go b/dbft_test.go index adb5cb14..72bf53af 100644 --- a/dbft_test.go +++ b/dbft_test.go @@ -25,6 +25,7 @@ type testState struct { currHeight uint32 currHash crypto.Uint256 pool *testPool + preBlocks []dbft.PreBlock[crypto.Uint256] blocks []dbft.Block[crypto.Uint256] verify func(b dbft.Block[crypto.Uint256]) bool } @@ -744,6 +745,67 @@ func TestDBFT_FourGoodNodesDeadlock(t *testing.T) { require.NotNil(t, r1.nextBlock()) } +func TestDBFT_OnReceiveCommitAck(t *testing.T) { + s := newTestState(2, 4) + t.Run("send commit after enough responses", func(t *testing.T) { + s.currHeight = 1 + service, _ := dbft.New[crypto.Uint256](s.getAMEVOptions()...) + service.Start(0) + + req := s.tryRecv() + require.NotNil(t, req) + + resp := s.getPrepareResponse(1, req.Hash()) + service.OnReceive(resp) + require.Nil(t, s.tryRecv()) + + resp = s.getPrepareResponse(0, req.Hash()) + service.OnReceive(resp) + + cm := s.tryRecv() + require.NotNil(t, cm) + require.Equal(t, dbft.CommitType, cm.Type()) + require.EqualValues(t, s.currHeight+1, cm.Height()) + require.EqualValues(t, 0, cm.ViewNumber()) + require.EqualValues(t, s.myIndex, cm.ValidatorIndex()) + require.NotNil(t, cm.Payload()) + + pub := s.pubs[s.myIndex] + require.NoError(t, service.PreHeader().Verify(pub, cm.GetCommit().Signature())) + + t.Run("send commitAck after enough commits", func(t *testing.T) { + s0 := s.copyWithIndex(0) + require.NoError(t, service.PreHeader().SetData(s0.privs[0])) + c0 := s0.getAMEVCommit(0, service.PreHeader().Data()) + service.OnReceive(c0) + require.Nil(t, s.tryRecv()) + require.Nil(t, s.nextPreBlock()) + require.Nil(t, s.nextBlock()) + + s1 := s.copyWithIndex(1) + require.NoError(t, service.PreHeader().SetData(s1.privs[1])) + c1 := s1.getAMEVCommit(1, service.PreHeader().Data()) + service.OnReceive(c1) + + // TODO: check PreBlock somehow, it doesn't have a lot of public interfaces + // (and it doesn't need them in fact). But for test we need to have an ability + // to ensure it contains expected. + // require.Equal(t, s.currHeight+1, b.Index()) + b := s.nextPreBlock() + require.NotNil(t, b) + require.Nil(t, s.nextBlock()) + + cmAck := s.tryRecv() + require.NotNil(t, cmAck) + require.Equal(t, dbft.CommitAckType, cmAck.Type()) + require.EqualValues(t, s.currHeight+1, cmAck.Height()) + require.EqualValues(t, 0, cmAck.ViewNumber()) + require.EqualValues(t, s.myIndex, cmAck.ValidatorIndex()) + require.NotNil(t, cmAck.Payload()) + }) + }) +} + func (s testState) getChangeView(from uint16, view byte) Payload { cv := consensus.NewChangeView(view, 0, 0) @@ -762,6 +824,12 @@ func (s testState) getCommit(from uint16, sign []byte) Payload { return p } +func (s testState) getAMEVCommit(from uint16, sign []byte) Payload { + c := consensus.NewAMEVCommit(sign) + p := consensus.NewConsensusPayload(dbft.CommitType, s.currHeight+1, from, 0, c) + return p +} + func (s testState) getPrepareResponse(from uint16, phash crypto.Uint256) Payload { resp := consensus.NewPrepareResponse(phash) @@ -814,6 +882,17 @@ func (s *testState) nextBlock() dbft.Block[crypto.Uint256] { return b } +func (s *testState) nextPreBlock() dbft.PreBlock[crypto.Uint256] { + if len(s.preBlocks) == 0 { + return nil + } + + b := s.preBlocks[0] + s.preBlocks = s.preBlocks[1:] + + return b +} + func (s testState) copyWithIndex(myIndex int) *testState { return &testState{ myIndex: myIndex, @@ -875,6 +954,20 @@ func (s *testState) getOptions() []func(*dbft.Config[crypto.Uint256]) { return opts } +func (s *testState) getAMEVOptions() []func(*dbft.Config[crypto.Uint256]) { + opts := s.getOptions() + opts = append(opts, + dbft.WithAntiMEVExtensionEnablingHeight[crypto.Uint256](0), + dbft.WithNewCommit[crypto.Uint256](consensus.NewAMEVCommit), + dbft.WithNewCommitAck[crypto.Uint256](consensus.NewCommitAck), + dbft.WithNewPreBlockFromContext[crypto.Uint256](newPreBlockFromContext), + dbft.WithNewBlockFromContext[crypto.Uint256](newAMEVBlockFromContext), + dbft.WithProcessPreBlock(func(b dbft.PreBlock[crypto.Uint256]) { s.preBlocks = append(s.preBlocks, b) }), + ) + + return opts +} + func newBlockFromContext(ctx *dbft.Context[crypto.Uint256]) dbft.Block[crypto.Uint256] { if ctx.TransactionHashes == nil { return nil @@ -883,6 +976,28 @@ func newBlockFromContext(ctx *dbft.Context[crypto.Uint256]) dbft.Block[crypto.Ui return block } +func newPreBlockFromContext(ctx *dbft.Context[crypto.Uint256]) dbft.PreBlock[crypto.Uint256] { + if ctx.TransactionHashes == nil { + return nil + } + pre := consensus.NewPreBlock(ctx.Timestamp, ctx.BlockIndex, ctx.PrevHash, ctx.Nonce, ctx.TransactionHashes) + return pre +} + +func newAMEVBlockFromContext(ctx *dbft.Context[crypto.Uint256]) dbft.Block[crypto.Uint256] { + if ctx.TransactionHashes == nil { + return nil + } + var data [][]byte + for _, c := range ctx.CommitPayloads { + if c != nil && c.ViewNumber() == ctx.ViewNumber { + data = append(data, c.GetCommit().Signature()) + } + } + pre := consensus.NewAMEVBlock(ctx.PreBlock(), data, ctx.M()) + return pre +} + // newConsensusPayload is a function for creating consensus payload of specific // type. func newConsensusPayload(c *dbft.Context[crypto.Uint256], t dbft.MessageType, msg any) dbft.ConsensusPayload[crypto.Uint256] { diff --git a/internal/consensus/amev_block.go b/internal/consensus/amev_block.go new file mode 100644 index 00000000..6d33e5fe --- /dev/null +++ b/internal/consensus/amev_block.go @@ -0,0 +1,126 @@ +package consensus + +import ( + "bytes" + "encoding/binary" + "encoding/gob" + "math" + + "github.com/nspcc-dev/dbft" + "github.com/nspcc-dev/dbft/internal/crypto" + "github.com/nspcc-dev/dbft/internal/merkle" +) + +type amevBlock struct { + base + + transactions []dbft.Transaction[crypto.Uint256] + signature []byte + hash *crypto.Uint256 +} + +// NewAMEVBlock returns new block based on PreBlock and additional Commit-level data +// collected from M consensus nodes. +func NewAMEVBlock(pre dbft.PreBlock[crypto.Uint256], cnData [][]byte, m int) dbft.Block[crypto.Uint256] { + preB := pre.(*preBlock) + res := new(amevBlock) + res.base = preB.base + + // Based on the provided cnData we'll add one more transaction to the resulting block. + // Some artificial rules of new tx creation are invented here, but in Neo X there will + // be well-defined custom rules for Envelope transactions. + var sum uint32 + for i := 0; i < m; i++ { + sum += binary.BigEndian.Uint32(cnData[i]) + } + tx := Tx64(math.MaxInt64 - int64(sum)) + res.transactions = append(preB.initialTransactions, &tx) + + // Rebuild Merkle root for the new set of transations. + txHashes := make([]crypto.Uint256, len(res.transactions)) + for i := range txHashes { + txHashes[i] = res.transactions[i].Hash() + } + mt := merkle.NewMerkleTree(txHashes...) + res.base.MerkleRoot = mt.Root().Hash + + return res +} + +// PrevHash implements Block interface. +func (b *amevBlock) PrevHash() crypto.Uint256 { + return b.base.PrevHash +} + +// Index implements Block interface. +func (b *amevBlock) Index() uint32 { + return b.base.Index +} + +// MerkleRoot implements Block interface. +func (b *amevBlock) MerkleRoot() crypto.Uint256 { + return b.base.MerkleRoot +} + +// Transactions implements Block interface. +func (b *amevBlock) Transactions() []dbft.Transaction[crypto.Uint256] { + return b.transactions +} + +// SetTransactions implements Block interface. This method is special since it's +// left for dBFT 2.0 compatibility and must not be called for amevBlock. +func (b *amevBlock) SetTransactions(txx []dbft.Transaction[crypto.Uint256]) { + panic("MUST NOT BE CALLED BY DBFT") +} + +// Signature implements Block interface. +func (b *amevBlock) Signature() []byte { + return b.signature +} + +// GetHashData returns data for hashing and signing. +// It must be an injection of the set of blocks to the set +// of byte slices, i.e: +// 1. It must have only one valid result for one block. +// 2. Two different blocks must have different hash data. +func (b *amevBlock) GetHashData() []byte { + buf := bytes.Buffer{} + w := gob.NewEncoder(&buf) + _ = b.EncodeBinary(w) + + return buf.Bytes() +} + +// Sign implements Block interface. +func (b *amevBlock) Sign(key dbft.PrivateKey) error { + data := b.GetHashData() + + sign, err := key.Sign(data) + if err != nil { + return err + } + + b.signature = sign + + return nil +} + +// Verify implements Block interface. +func (b *amevBlock) Verify(pub dbft.PublicKey, sign []byte) error { + data := b.GetHashData() + return pub.(*crypto.ECDSAPub).Verify(data, sign) +} + +// Hash implements Block interface. +func (b *amevBlock) Hash() (h crypto.Uint256) { + if b.hash != nil { + return *b.hash + } else if b.transactions == nil { + return + } + + hash := crypto.Hash256(b.GetHashData()) + b.hash = &hash + + return hash +} diff --git a/internal/consensus/amev_commit.go b/internal/consensus/amev_commit.go new file mode 100644 index 00000000..5a926b71 --- /dev/null +++ b/internal/consensus/amev_commit.go @@ -0,0 +1,44 @@ +package consensus + +import ( + "encoding/binary" + "encoding/gob" + + "github.com/nspcc-dev/dbft" +) + +type ( + amevCommit struct { + magic uint32 // some magic data CN have to exchange to properly construct final amevBlock. + } + // commitAux is an auxiliary structure for commit encoding. + amevCommitAux struct { + Magic uint32 + } +) + +var _ dbft.Commit = (*amevCommit)(nil) + +// EncodeBinary implements Serializable interface. +func (c amevCommit) EncodeBinary(w *gob.Encoder) error { + return w.Encode(amevCommitAux{ + Magic: c.magic, + }) +} + +// DecodeBinary implements Serializable interface. +func (c *amevCommit) DecodeBinary(r *gob.Decoder) error { + aux := new(amevCommitAux) + if err := r.Decode(aux); err != nil { + return err + } + c.magic = aux.Magic + return nil +} + +// Signature implements Commit interface. +func (c amevCommit) Signature() []byte { + res := make([]byte, 4) + binary.BigEndian.PutUint32(res, c.magic) + return res +} diff --git a/internal/consensus/amev_commitAck.go b/internal/consensus/amev_commitAck.go new file mode 100644 index 00000000..c41cc711 --- /dev/null +++ b/internal/consensus/amev_commitAck.go @@ -0,0 +1,44 @@ +package consensus + +import ( + "encoding/gob" + + "github.com/nspcc-dev/dbft" +) + +type ( + // commitAck implements dbft.CommitAck and holds some side data. + commitAck struct { + data [dataSize]byte + } + // commitAckAux is an auxiliary structure for commitAck encoding. + commitAckAux struct { + Data [dataSize]byte + } +) + +const dataSize = 20 + +var _ dbft.CommitAck = (*commitAck)(nil) + +// EncodeBinary implements Serializable interface. +func (c commitAck) EncodeBinary(w *gob.Encoder) error { + return w.Encode(commitAckAux{ + Data: c.data, + }) +} + +// DecodeBinary implements Serializable interface. +func (c *commitAck) DecodeBinary(r *gob.Decoder) error { + aux := new(commitAckAux) + if err := r.Decode(aux); err != nil { + return err + } + c.data = aux.Data + return nil +} + +// Data implements CommitAck interface. +func (c commitAck) Data() []byte { + return c.data[:] +} diff --git a/internal/consensus/amev_preblock.go b/internal/consensus/amev_preblock.go new file mode 100644 index 00000000..dac293c6 --- /dev/null +++ b/internal/consensus/amev_preblock.go @@ -0,0 +1,76 @@ +package consensus + +import ( + "encoding/binary" + "errors" + + "github.com/nspcc-dev/dbft" + "github.com/nspcc-dev/dbft/internal/crypto" + "github.com/nspcc-dev/dbft/internal/merkle" +) + +type preBlock struct { + base + + // A magic number CN nodes should exchange during Commit phase + // and used to construct the final list of transactions for amevBlock. + data uint32 + + initialTransactions []dbft.Transaction[crypto.Uint256] +} + +var _ dbft.PreBlock[crypto.Uint256] = new(preBlock) + +// NewPreBlock returns new preBlock. +func NewPreBlock(timestamp uint64, index uint32, prevHash crypto.Uint256, nonce uint64, txHashes []crypto.Uint256) dbft.PreBlock[crypto.Uint256] { + pre := new(preBlock) + pre.base.Timestamp = uint32(timestamp / 1000000000) + pre.base.Index = index + + // NextConsensus and Version information is not provided by dBFT context, + // these are implementation-specific fields, and thus, should be managed outside the + // dBFT library. For simulation simplicity, let's assume that these fields are filled + // by every CN separately and is not verified. + pre.base.NextConsensus = crypto.Uint160{1, 2, 3} + pre.base.Version = 0 + + pre.base.PrevHash = prevHash + pre.base.ConsensusData = nonce + + if len(txHashes) != 0 { + mt := merkle.NewMerkleTree(txHashes...) + pre.base.MerkleRoot = mt.Root().Hash + } + return pre +} + +func (pre *preBlock) Data() []byte { + var res = make([]byte, 4) + binary.BigEndian.PutUint32(res, pre.data) + return res +} + +func (pre *preBlock) SetData(key dbft.PrivateKey) error { + // Just an artificial rule for data construction, it can be anything, and in Neo X + // it will be decrypted transactions fragments. + pre.data = pre.base.Index + return nil +} + +func (pre *preBlock) Verify(key dbft.PublicKey, data []byte) error { + if len(data) != 4 { + return errors.New("invalid data len") + } + if binary.BigEndian.Uint32(data) != pre.base.Index { // Just an artificial verification rule, and for NeoX it should be decrypted transactions fragments verification. + return errors.New("invalid data") + } + return nil +} + +func (pre *preBlock) Transactions() []dbft.Transaction[crypto.Uint256] { + return pre.initialTransactions +} + +func (pre *preBlock) SetTransactions(txs []dbft.Transaction[crypto.Uint256]) { + pre.initialTransactions = txs +} diff --git a/internal/consensus/constructors.go b/internal/consensus/constructors.go index 44326b02..296e6655 100644 --- a/internal/consensus/constructors.go +++ b/internal/consensus/constructors.go @@ -1,6 +1,8 @@ package consensus import ( + "encoding/binary" + "github.com/nspcc-dev/dbft" "github.com/nspcc-dev/dbft/internal/crypto" ) @@ -49,6 +51,20 @@ func NewCommit(signature []byte) dbft.Commit { return c } +// NewAMEVCommit returns minimal CommitAck implementation for Anti-MEV extension. +func NewAMEVCommit(data []byte) dbft.Commit { + c := new(amevCommit) + c.magic = binary.BigEndian.Uint32(data) + return c +} + +// NewCommitAck returns minimal CommitAck implementation. +func NewCommitAck(data []byte) dbft.CommitAck { + c := new(commitAck) + copy(c.data[:], data) + return c +} + // NewRecoveryRequest returns minimal RecoveryRequest implementation. func NewRecoveryRequest(ts uint64) dbft.RecoveryRequest { return &recoveryRequest{ diff --git a/pre_block.go b/pre_block.go index 2931c38a..b75c5cb4 100644 --- a/pre_block.go +++ b/pre_block.go @@ -2,9 +2,6 @@ package dbft // PreBlock is a generic interface for a preBlock used by anti-MEV dBFT extension. type PreBlock[H Hash] interface { - // Hash returns PreBlock hash. - Hash() H // needed for informational log, but may be removed. - // Data returns PreBlock's data CNs need to exchange during Commit phase. // It's not a final block signature. Data() []byte // required