diff --git a/rocketpool/watchtower/submit-rpl-price.go b/rocketpool/watchtower/submit-rpl-price.go index 264a092a0..ad8c0d08b 100644 --- a/rocketpool/watchtower/submit-rpl-price.go +++ b/rocketpool/watchtower/submit-rpl-price.go @@ -3,6 +3,7 @@ package watchtower import ( "bytes" "context" + "encoding/binary" "fmt" "math/big" "strings" @@ -236,6 +237,46 @@ const ( twapNumberOfSeconds uint32 = 60 * 60 * 12 // 12 hours ) +// getIndexToSubmit deterministically selects the ODAO member index whose turn +// it is to submit during the turn that contains the given block number. +// The shuffle is derived from a keccak256-seeded permutation for the same +func getIndexToSubmit(blockNumber, count uint64) uint64 { + if count <= 1 { + return 0 + } + + turn := blockNumber / BlocksPerTurn + epoch := turn / count + position := turn % count + + // Seed the shuffle with keccak256(epoch || count) so that adding or + // removing an ODAO member rerolls the order rather than producing a + // permutation that happens to overlap with a previous one. + var seed [16]byte + binary.BigEndian.PutUint64(seed[0:8], epoch) + binary.BigEndian.PutUint64(seed[8:16], count) + digest := crypto.Keccak256(seed[:]) + + perm := make([]uint64, count) + for i := range perm { + perm[i] = uint64(i) + } + + offset := 0 + for i := count - 1; i > 0; i-- { + if offset+8 > len(digest) { + digest = crypto.Keccak256(digest) + offset = 0 + } + r := binary.BigEndian.Uint64(digest[offset : offset+8]) + offset += 8 + j := r % (i + 1) + perm[i], perm[j] = perm[j], perm[i] + } + + return perm[position] +} + type poolObserveResponse struct { TickCumulatives []*big.Int `abi:"tickCumulatives"` SecondsPerLiquidityCumulativeX128s []*big.Int `abi:"secondsPerLiquidityCumulativeX128s"` @@ -739,7 +780,7 @@ func (t *submitRplPrice) submitOptimismPrice() error { } // Calculate whose turn it is to submit - indexToSubmit := (blockNumber / BlocksPerTurn) % count + indexToSubmit := getIndexToSubmit(blockNumber, count) if index == indexToSubmit { @@ -860,7 +901,7 @@ func (t *submitRplPrice) submitPolygonPrice() error { } // Calculate whose turn it is to submit - indexToSubmit := (blockNumber / BlocksPerTurn) % count + indexToSubmit := getIndexToSubmit(blockNumber, count) if index == indexToSubmit { @@ -979,7 +1020,7 @@ func (t *submitRplPrice) submitArbitrumPrice(priceMessengerAddress string) error } // Calculate whose turn it is to submit - indexToSubmit := (blockNumber / BlocksPerTurn) % count + indexToSubmit := getIndexToSubmit(blockNumber, count) if index == indexToSubmit { @@ -1125,7 +1166,7 @@ func (t *submitRplPrice) submitZkSyncEraPrice() error { } // Calculate whose turn it is to submit - indexToSubmit := (blockNumber / BlocksPerTurn) % count + indexToSubmit := getIndexToSubmit(blockNumber, count) if index == indexToSubmit { @@ -1264,7 +1305,7 @@ func (t *submitRplPrice) submitBasePrice() error { } // Calculate whose turn it is to submit - indexToSubmit := (blockNumber / BlocksPerTurn) % count + indexToSubmit := getIndexToSubmit(blockNumber, count) if index == indexToSubmit { @@ -1385,7 +1426,7 @@ func (t *submitRplPrice) submitScrollPrice() error { } // Calculate whose turn it is to submit - indexToSubmit := (blockNumber / BlocksPerTurn) % count + indexToSubmit := getIndexToSubmit(blockNumber, count) if index == indexToSubmit { l2GasEstimatorAddress := t.cfg.Smartnode.GetScrollFeeEstimatorAddress() diff --git a/rocketpool/watchtower/submit-rpl-price_test.go b/rocketpool/watchtower/submit-rpl-price_test.go new file mode 100644 index 000000000..4328b79dc --- /dev/null +++ b/rocketpool/watchtower/submit-rpl-price_test.go @@ -0,0 +1,94 @@ +package watchtower + +import "testing" + +func TestGetIndexToSubmit_CountEdgeCases(t *testing.T) { + tests := []struct { + name string + blockNumber uint64 + count uint64 + expected uint64 + }{ + { + name: "zero members returns zero", + blockNumber: 0, + count: 0, + expected: 0, + }, + { + name: "one member always returns zero", + blockNumber: 123456789, + count: 1, + expected: 0, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := getIndexToSubmit(test.blockNumber, test.count) + if actual != test.expected { + t.Fatalf("wrong index: got %d, want %d", actual, test.expected) + } + }) + } +} + +func TestGetIndexToSubmit_Count10_PerEpochPermutationAndFairness(t *testing.T) { + const ( + count = uint64(10) + epochs = uint64(200) + ) + + totalTurnsByMember := make([]uint64, count) + firstTurnByMember := make([]uint64, count) + + for epoch := uint64(0); epoch < epochs; epoch++ { + seenThisEpoch := make([]bool, count) + + for position := uint64(0); position < count; position++ { + turn := epoch*count + position + turnStartBlock := turn * BlocksPerTurn + midTurnBlock := turnStartBlock + (BlocksPerTurn / 2) + + indexFromStart := getIndexToSubmit(turnStartBlock, count) + indexFromMidTurn := getIndexToSubmit(midTurnBlock, count) + + if indexFromStart != indexFromMidTurn { + t.Fatalf("index changed inside turn (epoch %d, position %d): start=%d mid=%d", + epoch, position, indexFromStart, indexFromMidTurn) + } + + if indexFromStart >= count { + t.Fatalf("index out of range (epoch %d, position %d): got %d, count=%d", + epoch, position, indexFromStart, count) + } + + if seenThisEpoch[indexFromStart] { + t.Fatalf("duplicate member in epoch %d: member %d appears more than once", + epoch, indexFromStart) + } + seenThisEpoch[indexFromStart] = true + totalTurnsByMember[indexFromStart]++ + + if position == 0 { + firstTurnByMember[indexFromStart]++ + } + } + + for member := uint64(0); member < count; member++ { + if !seenThisEpoch[member] { + t.Fatalf("epoch %d is missing member %d", epoch, member) + } + } + } + + // Every epoch is a full permutation, so every member appears exactly once per + // epoch and therefore exactly `epochs` times overall. + for member := uint64(0); member < count; member++ { + if totalTurnsByMember[member] != epochs { + t.Fatalf("member %d fairness mismatch: got %d turns, want %d", + member, totalTurnsByMember[member], epochs) + } + } + +}