Skip to content

Commit 73a97f1

Browse files
committed
Using a keccak-seed permutation for the submission order. Fixed the case where an absent member would penalize the next one
1 parent 0d6800d commit 73a97f1

2 files changed

Lines changed: 141 additions & 6 deletions

File tree

rocketpool/watchtower/submit-rpl-price.go

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package watchtower
33
import (
44
"bytes"
55
"context"
6+
"encoding/binary"
67
"fmt"
78
"math/big"
89
"strings"
@@ -236,6 +237,46 @@ const (
236237
twapNumberOfSeconds uint32 = 60 * 60 * 12 // 12 hours
237238
)
238239

240+
// getIndexToSubmit deterministically selects the ODAO member index whose turn
241+
// it is to submit during the turn that contains the given block number.
242+
// The shuffle is derived from a keccak256-seeded permutation for the same
243+
func getIndexToSubmit(blockNumber, count uint64) uint64 {
244+
if count <= 1 {
245+
return 0
246+
}
247+
248+
turn := blockNumber / BlocksPerTurn
249+
epoch := turn / count
250+
position := turn % count
251+
252+
// Seed the shuffle with keccak256(epoch || count) so that adding or
253+
// removing an ODAO member rerolls the order rather than producing a
254+
// permutation that happens to overlap with a previous one.
255+
var seed [16]byte
256+
binary.BigEndian.PutUint64(seed[0:8], epoch)
257+
binary.BigEndian.PutUint64(seed[8:16], count)
258+
digest := crypto.Keccak256(seed[:])
259+
260+
perm := make([]uint64, count)
261+
for i := range perm {
262+
perm[i] = uint64(i)
263+
}
264+
265+
offset := 0
266+
for i := count - 1; i > 0; i-- {
267+
if offset+8 > len(digest) {
268+
digest = crypto.Keccak256(digest)
269+
offset = 0
270+
}
271+
r := binary.BigEndian.Uint64(digest[offset : offset+8])
272+
offset += 8
273+
j := r % (i + 1)
274+
perm[i], perm[j] = perm[j], perm[i]
275+
}
276+
277+
return perm[position]
278+
}
279+
239280
type poolObserveResponse struct {
240281
TickCumulatives []*big.Int `abi:"tickCumulatives"`
241282
SecondsPerLiquidityCumulativeX128s []*big.Int `abi:"secondsPerLiquidityCumulativeX128s"`
@@ -739,7 +780,7 @@ func (t *submitRplPrice) submitOptimismPrice() error {
739780
}
740781

741782
// Calculate whose turn it is to submit
742-
indexToSubmit := (blockNumber / BlocksPerTurn) % count
783+
indexToSubmit := getIndexToSubmit(blockNumber, count)
743784

744785
if index == indexToSubmit {
745786

@@ -860,7 +901,7 @@ func (t *submitRplPrice) submitPolygonPrice() error {
860901
}
861902

862903
// Calculate whose turn it is to submit
863-
indexToSubmit := (blockNumber / BlocksPerTurn) % count
904+
indexToSubmit := getIndexToSubmit(blockNumber, count)
864905

865906
if index == indexToSubmit {
866907

@@ -979,7 +1020,7 @@ func (t *submitRplPrice) submitArbitrumPrice(priceMessengerAddress string) error
9791020
}
9801021

9811022
// Calculate whose turn it is to submit
982-
indexToSubmit := (blockNumber / BlocksPerTurn) % count
1023+
indexToSubmit := getIndexToSubmit(blockNumber, count)
9831024

9841025
if index == indexToSubmit {
9851026

@@ -1125,7 +1166,7 @@ func (t *submitRplPrice) submitZkSyncEraPrice() error {
11251166
}
11261167

11271168
// Calculate whose turn it is to submit
1128-
indexToSubmit := (blockNumber / BlocksPerTurn) % count
1169+
indexToSubmit := getIndexToSubmit(blockNumber, count)
11291170

11301171
if index == indexToSubmit {
11311172

@@ -1264,7 +1305,7 @@ func (t *submitRplPrice) submitBasePrice() error {
12641305
}
12651306

12661307
// Calculate whose turn it is to submit
1267-
indexToSubmit := (blockNumber / BlocksPerTurn) % count
1308+
indexToSubmit := getIndexToSubmit(blockNumber, count)
12681309

12691310
if index == indexToSubmit {
12701311

@@ -1385,7 +1426,7 @@ func (t *submitRplPrice) submitScrollPrice() error {
13851426
}
13861427

13871428
// Calculate whose turn it is to submit
1388-
indexToSubmit := (blockNumber / BlocksPerTurn) % count
1429+
indexToSubmit := getIndexToSubmit(blockNumber, count)
13891430

13901431
if index == indexToSubmit {
13911432
l2GasEstimatorAddress := t.cfg.Smartnode.GetScrollFeeEstimatorAddress()
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package watchtower
2+
3+
import "testing"
4+
5+
func TestGetIndexToSubmit_CountEdgeCases(t *testing.T) {
6+
tests := []struct {
7+
name string
8+
blockNumber uint64
9+
count uint64
10+
expected uint64
11+
}{
12+
{
13+
name: "zero members returns zero",
14+
blockNumber: 0,
15+
count: 0,
16+
expected: 0,
17+
},
18+
{
19+
name: "one member always returns zero",
20+
blockNumber: 123456789,
21+
count: 1,
22+
expected: 0,
23+
},
24+
}
25+
26+
for _, test := range tests {
27+
t.Run(test.name, func(t *testing.T) {
28+
actual := getIndexToSubmit(test.blockNumber, test.count)
29+
if actual != test.expected {
30+
t.Fatalf("wrong index: got %d, want %d", actual, test.expected)
31+
}
32+
})
33+
}
34+
}
35+
36+
func TestGetIndexToSubmit_Count10_PerEpochPermutationAndFairness(t *testing.T) {
37+
const (
38+
count = uint64(10)
39+
epochs = uint64(200)
40+
)
41+
42+
totalTurnsByMember := make([]uint64, count)
43+
firstTurnByMember := make([]uint64, count)
44+
45+
for epoch := uint64(0); epoch < epochs; epoch++ {
46+
seenThisEpoch := make([]bool, count)
47+
48+
for position := uint64(0); position < count; position++ {
49+
turn := epoch*count + position
50+
turnStartBlock := turn * BlocksPerTurn
51+
midTurnBlock := turnStartBlock + (BlocksPerTurn / 2)
52+
53+
indexFromStart := getIndexToSubmit(turnStartBlock, count)
54+
indexFromMidTurn := getIndexToSubmit(midTurnBlock, count)
55+
56+
if indexFromStart != indexFromMidTurn {
57+
t.Fatalf("index changed inside turn (epoch %d, position %d): start=%d mid=%d",
58+
epoch, position, indexFromStart, indexFromMidTurn)
59+
}
60+
61+
if indexFromStart >= count {
62+
t.Fatalf("index out of range (epoch %d, position %d): got %d, count=%d",
63+
epoch, position, indexFromStart, count)
64+
}
65+
66+
if seenThisEpoch[indexFromStart] {
67+
t.Fatalf("duplicate member in epoch %d: member %d appears more than once",
68+
epoch, indexFromStart)
69+
}
70+
seenThisEpoch[indexFromStart] = true
71+
totalTurnsByMember[indexFromStart]++
72+
73+
if position == 0 {
74+
firstTurnByMember[indexFromStart]++
75+
}
76+
}
77+
78+
for member := uint64(0); member < count; member++ {
79+
if !seenThisEpoch[member] {
80+
t.Fatalf("epoch %d is missing member %d", epoch, member)
81+
}
82+
}
83+
}
84+
85+
// Every epoch is a full permutation, so every member appears exactly once per
86+
// epoch and therefore exactly `epochs` times overall.
87+
for member := uint64(0); member < count; member++ {
88+
if totalTurnsByMember[member] != epochs {
89+
t.Fatalf("member %d fairness mismatch: got %d turns, want %d",
90+
member, totalTurnsByMember[member], epochs)
91+
}
92+
}
93+
94+
}

0 commit comments

Comments
 (0)