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
53 changes: 47 additions & 6 deletions rocketpool/watchtower/submit-rpl-price.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package watchtower
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"math/big"
"strings"
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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 {

Expand Down Expand Up @@ -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 {

Expand Down Expand Up @@ -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 {

Expand Down Expand Up @@ -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 {

Expand Down Expand Up @@ -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 {

Expand Down Expand Up @@ -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()
Expand Down
94 changes: 94 additions & 0 deletions rocketpool/watchtower/submit-rpl-price_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}

}
Loading