From cb6794a459e6057f814867ff5c20e77ff236b34d Mon Sep 17 00:00:00 2001 From: "Masih H. Derkani" Date: Mon, 2 Sep 2024 17:04:48 +0100 Subject: [PATCH] Test sway at commit and converge for unseen candidates Test that a participant sways for unseen candidates at `COMMIT` and `CONVERGE`. Part of #237 --- emulator/driver.go | 6 ++ emulator/driver_assertions.go | 21 ++++-- emulator/host.go | 11 ++- gpbft/gpbft_test.go | 122 ++++++++++++++++++++++++++++++++++ gpbft/ticket_rank.go | 2 +- 5 files changed, 154 insertions(+), 8 deletions(-) diff --git a/emulator/driver.go b/emulator/driver.go index d6b73c2e..3772220a 100644 --- a/emulator/driver.go +++ b/emulator/driver.go @@ -60,6 +60,12 @@ func (d *Driver) AddInstance(instance *Instance) { d.require.NoError(d.host.addInstance(instance)) } +// PeekLastBroadcastRequest gets the last broadcast requested by the subject +// participant without removing it from the pending broadcasts. +func (d *Driver) PeekLastBroadcastRequest() *gpbft.GMessage { + return d.host.peekLastBroadcast() +} + func (d *Driver) DeliverAlarm() (bool, error) { if d.host.maybeReceiveAlarm() { return true, d.subject.ReceiveAlarm() diff --git a/emulator/driver_assertions.go b/emulator/driver_assertions.go index 36bad697..2382222f 100644 --- a/emulator/driver_assertions.go +++ b/emulator/driver_assertions.go @@ -32,11 +32,11 @@ func (d *Driver) RequireDeliverAlarm() { } func (d *Driver) RequireNoBroadcast() { - d.require.Nil(d.host.popReceivedBroadcast()) + d.require.Nil(d.host.popNextBroadcast()) } func (d *Driver) RequireQuality() { - msg := d.host.popReceivedBroadcast() + msg := d.host.popNextBroadcast() d.require.NotNil(msg) instance := d.host.getInstance(msg.Vote.Instance) d.require.NotNil(instance) @@ -56,7 +56,7 @@ func (d *Driver) RequirePrepare(value gpbft.ECChain) { } func (d *Driver) RequirePrepareAtRound(round uint64, value gpbft.ECChain, justification *gpbft.Justification) { - msg := d.host.popReceivedBroadcast() + msg := d.host.popNextBroadcast() d.require.NotNil(msg) instance := d.host.getInstance(msg.Vote.Instance) d.require.NotNil(instance) @@ -76,7 +76,7 @@ func (d *Driver) RequireCommitForBottom(round uint64) { } func (d *Driver) RequireCommit(round uint64, vote gpbft.ECChain, justification *gpbft.Justification) { - msg := d.host.popReceivedBroadcast() + msg := d.host.popNextBroadcast() d.require.NotNil(msg) instance := d.host.getInstance(msg.Vote.Instance) d.require.NotNil(instance) @@ -92,7 +92,7 @@ func (d *Driver) RequireCommit(round uint64, vote gpbft.ECChain, justification * } func (d *Driver) RequireConverge(round uint64, vote gpbft.ECChain, justification *gpbft.Justification) { - msg := d.host.popReceivedBroadcast() + msg := d.host.popNextBroadcast() d.require.NotNil(msg) instance := d.host.getInstance(msg.Vote.Instance) d.require.NotNil(instance) @@ -108,7 +108,7 @@ func (d *Driver) RequireConverge(round uint64, vote gpbft.ECChain, justification } func (d *Driver) RequireDecide(vote gpbft.ECChain, justification *gpbft.Justification) { - msg := d.host.popReceivedBroadcast() + msg := d.host.popNextBroadcast() d.require.NotNil(msg) instance := d.host.getInstance(msg.Vote.Instance) d.require.NotNil(instance) @@ -130,3 +130,12 @@ func (d *Driver) RequireDecision(instanceID uint64, expect gpbft.ECChain) { d.require.NotNil(decision) d.require.Equal(expect, decision.Vote.Value) } + +// RequirePeekAtLastVote asserts that the last message broadcasted by the subject +// participant was for the given phase, round and vote. +func (d *Driver) RequirePeekAtLastVote(phase gpbft.Phase, round uint64, vote gpbft.ECChain) { + last := d.host.peekLastBroadcast() + d.require.Equal(phase, last.Vote.Phase, "Expected last vote phase %s, but got %s", phase, last.Vote.Phase) + d.require.Equal(round, last.Vote.Round, "Expected last vote round %d, but got %d", round, last.Vote.Round) + d.require.Equal(vote, last.Vote.Value, "Expected last vote value %s, but got %s", vote, last.Vote.Value) +} diff --git a/emulator/host.go b/emulator/host.go index 122b5056..7fdafc79 100644 --- a/emulator/host.go +++ b/emulator/host.go @@ -96,7 +96,7 @@ func (h *driverHost) getInstance(id uint64) *Instance { return h.chain[id] } -func (h *driverHost) popReceivedBroadcast() *gpbft.GMessage { +func (h *driverHost) popNextBroadcast() *gpbft.GMessage { switch len(h.receivedBroadcasts) { case 0: return nil @@ -107,6 +107,15 @@ func (h *driverHost) popReceivedBroadcast() *gpbft.GMessage { } } +func (h *driverHost) peekLastBroadcast() *gpbft.GMessage { + switch l := len(h.receivedBroadcasts); l { + case 0: + return nil + default: + return h.receivedBroadcasts[l-1] + } +} + func (h *driverHost) NetworkName() gpbft.NetworkName { return networkName } func (h *driverHost) Time() time.Time { return h.now } func (h *driverHost) SetAlarm(at time.Time) { h.pendingAlarm = &at } diff --git a/gpbft/gpbft_test.go b/gpbft/gpbft_test.go index 337896af..24869db8 100644 --- a/gpbft/gpbft_test.go +++ b/gpbft/gpbft_test.go @@ -1622,3 +1622,125 @@ func TestGPBFT_DropOld(t *testing.T) { // But we should still accept decides from the latest instance. driver.RequireDeliverMessage(newDecide0) } + +func TestGPBFT_Sway(t *testing.T) { + t.Parallel() + newInstanceAndDriver := func(t *testing.T) (*emulator.Instance, *emulator.Driver) { + driver := emulator.NewDriver(t) + instance := emulator.NewInstance(t, + 0, + gpbft.PowerEntries{ + gpbft.PowerEntry{ + ID: 0, + Power: gpbft.NewStoragePower(2), + }, + gpbft.PowerEntry{ + ID: 1, + Power: gpbft.NewStoragePower(2), + }, + gpbft.PowerEntry{ + ID: 2, + Power: gpbft.NewStoragePower(1), + }, + gpbft.PowerEntry{ + ID: 3, + Power: gpbft.NewStoragePower(2), + }, + }, + tipset0, tipSet1, tipSet2, tipSet3, tipSet4, + ) + driver.AddInstance(instance) + driver.RequireNoBroadcast() + return instance, driver + } + + swayed := func(t *testing.T) bool { + instance, driver := newInstanceAndDriver(t) + driver.RequireStartInstance(instance.ID()) + baseProposal := instance.Proposal().BaseChain() + proposal2 := baseProposal.Extend(tipSet1.Key) + proposal1 := proposal2.Extend(tipSet2.Key).Extend(tipSet3.Key) + + // Trigger alarm to immediately complete QUALITY. + driver.RequireDeliverAlarm() + driver.RequirePeekAtLastVote(gpbft.PREPARE_PHASE, 0, baseProposal) + + // Deliver PREPARE messages such that reaching quorum is impossible which should + // complete the phase. + driver.RequireDeliverMessage(&gpbft.GMessage{ + Sender: 0, + Vote: instance.NewPrepare(0, instance.Proposal()), + }) + driver.RequireDeliverMessage(&gpbft.GMessage{ + Sender: 1, + Vote: instance.NewPrepare(0, proposal1), + }) + driver.RequireDeliverMessage(&gpbft.GMessage{ + Sender: 2, + Vote: instance.NewPrepare(0, proposal2), + }) + driver.RequirePeekAtLastVote(gpbft.COMMIT_PHASE, 0, gpbft.ECChain{}) + + // Deliver COMMIT messages and trigger timeout to complete the phase but with no + // strong quorum. This should progress the instance to CONVERGE at round 1. + driver.RequireDeliverMessage(&gpbft.GMessage{ + Sender: 1, + Vote: instance.NewCommit(0, proposal2), + Justification: instance.NewJustification(0, gpbft.PREPARE_PHASE, proposal2, 1, 3, 2), + }) + driver.RequireDeliverMessage(&gpbft.GMessage{ + Sender: 0, + Vote: instance.NewCommit(0, gpbft.ECChain{}), + }) + driver.RequireDeliverMessage(&gpbft.GMessage{ + Sender: 3, + Vote: instance.NewCommit(0, proposal1), + Justification: instance.NewJustification(0, gpbft.PREPARE_PHASE, proposal1, 1, 3, 2), + }) + driver.RequireDeliverAlarm() + + // Assert sway from base to either proposal1 or proposal2 at COMMIT. + latestBroadcast := driver.PeekLastBroadcastRequest() + require.Equal(t, gpbft.CONVERGE_PHASE, latestBroadcast.Vote.Phase) + require.EqualValues(t, 1, latestBroadcast.Vote.Round) + swayedToProposal2AtCommit := latestBroadcast.Vote.Value.Eq(proposal2) + require.True(t, latestBroadcast.Vote.Value.Eq(proposal1) || swayedToProposal2AtCommit) + if !swayedToProposal2AtCommit { + // Only proceed if swayed to proposal2 at COMMIT, so that we can test sway to + // proposal1 at CONVERGE as an unseen proposal. Otherwise, it is possible that + // CONVERGE may stick with proposal2, i.e. an already seen candidate. + return false + } + + // Deliver converge message for alternative proposal with strong quorum of + // PREPARE, which should sway the subject. Then trigger alarm to end CONVERGE. + driver.RequireDeliverMessage(&gpbft.GMessage{ + Sender: 3, + Vote: instance.NewConverge(1, proposal1), + Ticket: emulator.ValidTicket, + Justification: instance.NewJustification(0, gpbft.PREPARE_PHASE, proposal1, 0, 1, 3, 2), + }) + driver.RequireDeliverAlarm() + + // Only pass if CONVERGE swayed to either proposal1 or proposal2 but not the same + // as whichever COMMIT swayed to. + latestBroadcast = driver.PeekLastBroadcastRequest() + require.Equal(t, gpbft.PREPARE_PHASE, latestBroadcast.Vote.Phase) + require.EqualValues(t, 1, latestBroadcast.Vote.Round) + swayedToProposal1AtConverge := latestBroadcast.Vote.Value.Eq(proposal1) + require.True(t, swayedToProposal1AtConverge || latestBroadcast.Vote.Value.Eq(proposal2)) + return swayedToProposal1AtConverge + } + + // Swaying is nondeterministic. Require that eventually we sway to one proposal in + // COMMIT and other in CONVERGE. + t.Run("Sways to alternative unseen proposal at COMMIT and CONVERGE", func(t *testing.T) { + // Try 10 times to hit the exact sway we want. + for i := 0; i < 10; i++ { + if swayed(t) { + return + } + } + require.Fail(t, "after 10 tries did not swayed to proposals 1 and 2 at CONVERGE and COMMIT, respectively.") + }) +} diff --git a/gpbft/ticket_rank.go b/gpbft/ticket_rank.go index 69ff41f1..982d8623 100644 --- a/gpbft/ticket_rank.go +++ b/gpbft/ticket_rank.go @@ -10,7 +10,7 @@ import ( // ComputeTicketRank computes a rank for the given ticket, weighted by a // participant's power. A lower rank value indicates a better ranking. The -// process involves the following steps: +// process involves the following phases: // // 1. Hash the ticket using the Blake2b256 hash function. // 2. Extract the low 128 bits from the hash and interpret them as a Q.128