Files
jbergner bcfbef390f
release-tag / release-image (push) Failing after 2m44s
RC-7
2026-08-11 16:58:07 +02:00

285 lines
7.7 KiB
Go

package server
import (
"context"
crand "crypto/rand"
"crypto/sha256"
"encoding/binary"
"errors"
"math/big"
"sync"
"time"
)
var (
errLotteryDuplicate = errors.New("guess already entered in current lottery window")
errLotteryFull = errors.New("guess lottery window is full")
errBeaconUnavailable = errors.New("external randomness beacon unavailable")
)
const maxLotteryTicketsPerWindow = 250000
type lotteryResult struct {
Selected bool `json:"selected"`
BeaconEnabled bool `json:"beacon_enabled"`
ChosenPath string `json:"chosen_path,omitempty"`
BoostedPath string `json:"boosted_path,omitempty"`
BeaconSource string `json:"beacon_source,omitempty"`
BeaconID string `json:"beacon_id,omitempty"`
BeaconRound uint64 `json:"beacon_round,omitempty"`
Randomness string `json:"randomness,omitempty"`
Weight int `json:"weight,omitempty"`
WindowEnd int64 `json:"window_end,omitempty"`
}
type lotteryTicket struct {
key string
path string
ctx context.Context
result chan lotteryDelivery
}
type lotteryDelivery struct {
result lotteryResult
err error
}
type lotteryBucket struct {
max int
end time.Time
tickets []*lotteryTicket
keys map[string]struct{}
beacon bool
bonusWeight int
beaconRound uint64
revealAt time.Time
}
type beaconDrawAudit struct {
TaskID string
WindowEnd time.Time
BeaconID string
BeaconRound uint64
Randomness string
Signature string
BoostedPath string
Tickets int
Selected int
}
type guessLottery struct {
mu sync.Mutex
buckets map[string]*lotteryBucket
beacon *beaconClient
onDraw func(beaconDrawAudit)
}
func newGuessLottery(onDraw func(beaconDrawAudit)) *guessLottery {
return &guessLottery{buckets: make(map[string]*lotteryBucket), beacon: newBeaconClient(), onDraw: onDraw}
}
// enter batches all valid tips for a task into aligned time windows. When
// Beacon Hunt is enabled, players commit to PULSE/FLUX/ORBIT before the window
// closes. The first drand round after the boundary becomes the deterministic
// source for both the boosted path and the weighted draw.
func (l *guessLottery) enter(ctx context.Context, taskID, clientID string, seq int64, path string, window time.Duration, max int, beaconEnabled bool, bonusWeight int) (lotteryResult, error) {
if max <= 0 || window <= 0 {
return lotteryResult{Selected: true}, nil
}
if beaconEnabled {
path = normalizeBeaconPath(path)
if path == "" {
return lotteryResult{}, errors.New("beacon path required")
}
if bonusWeight < 1 {
bonusWeight = 1
}
}
now := time.Now().UTC()
windowNS := window.Nanoseconds()
if windowNS <= 0 {
return lotteryResult{Selected: true}, nil
}
idx := now.UnixNano() / windowNS
end := time.Unix(0, (idx+1)*windowNS).UTC()
bucketKey := taskID + "|" + end.Format(time.RFC3339Nano) + "|" + window.String()
ticketKey := clientID + "|" + big.NewInt(seq).String()
t := &lotteryTicket{key: ticketKey, path: path, ctx: ctx, result: make(chan lotteryDelivery, 1)}
l.mu.Lock()
b := l.buckets[bucketKey]
if b == nil {
b = &lotteryBucket{max: max, end: end, keys: make(map[string]struct{}), beacon: beaconEnabled, bonusWeight: bonusWeight}
if beaconEnabled {
planCtx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
round, revealAt, err := l.beacon.plan(planCtx, end)
cancel()
if err != nil {
l.mu.Unlock()
return lotteryResult{}, errBeaconUnavailable
}
b.beaconRound, b.revealAt = round, revealAt
}
l.buckets[bucketKey] = b
drawAt := end
if b.beacon && b.revealAt.After(drawAt) {
drawAt = b.revealAt.Add(700 * time.Millisecond)
}
delay := time.Until(drawAt)
if delay < 0 {
delay = 0
}
time.AfterFunc(delay, func() { l.draw(bucketKey, taskID) })
} else if b.beacon != beaconEnabled {
l.mu.Unlock()
return lotteryResult{}, errors.New("lottery mode changed during active window")
}
if _, exists := b.keys[ticketKey]; exists {
l.mu.Unlock()
return lotteryResult{}, errLotteryDuplicate
}
if len(b.tickets) >= maxLotteryTicketsPerWindow {
l.mu.Unlock()
return lotteryResult{}, errLotteryFull
}
b.keys[ticketKey] = struct{}{}
b.tickets = append(b.tickets, t)
l.mu.Unlock()
select {
case d := <-t.result:
return d.result, d.err
case <-ctx.Done():
return lotteryResult{}, ctx.Err()
}
}
func (l *guessLottery) draw(bucketKey, taskID string) {
l.mu.Lock()
b := l.buckets[bucketKey]
if b == nil {
l.mu.Unlock()
return
}
delete(l.buckets, bucketKey)
tickets := append([]*lotteryTicket(nil), b.tickets...)
l.mu.Unlock()
alive := tickets[:0]
for _, t := range tickets {
select {
case <-t.ctx.Done():
default:
alive = append(alive, t)
}
}
tickets = alive
max := b.max
if max > len(tickets) {
max = len(tickets)
}
if b.beacon {
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
reveal, err := l.beacon.reveal(ctx, b.beaconRound)
cancel()
if err != nil {
for _, t := range tickets {
select {
case t.result <- lotteryDelivery{err: errBeaconUnavailable}:
default:
}
}
return
}
boosted := beaconPathFromRandomness(reveal.Randomness, bucketKey)
selected := weightedBeaconDraw(tickets, max, boosted, b.bonusWeight, reveal.Randomness, bucketKey)
selectedCount := 0
for _, yes := range selected {
if yes {
selectedCount++
}
}
for i, t := range tickets {
weight := 1
if t.path == boosted {
weight = b.bonusWeight
}
res := lotteryResult{Selected: selected[i], BeaconEnabled: true, ChosenPath: t.path, BoostedPath: boosted, BeaconSource: reveal.Source, BeaconID: reveal.BeaconID, BeaconRound: reveal.Round, Randomness: reveal.Randomness, Weight: weight, WindowEnd: b.end.Unix()}
select {
case t.result <- lotteryDelivery{result: res}:
default:
}
}
if l.onDraw != nil {
l.onDraw(beaconDrawAudit{TaskID: taskID, WindowEnd: b.end, BeaconID: reveal.BeaconID, BeaconRound: reveal.Round, Randomness: reveal.Randomness, Signature: reveal.Signature, BoostedPath: boosted, Tickets: len(tickets), Selected: selectedCount})
}
return
}
// Legacy lottery: partial Fisher-Yates with crypto/rand.
for i := 0; i < max; i++ {
nBig, err := crand.Int(crand.Reader, big.NewInt(int64(len(tickets)-i)))
if err != nil {
nBig = big.NewInt(0)
}
j := i + int(nBig.Int64())
tickets[i], tickets[j] = tickets[j], tickets[i]
}
for i, t := range tickets {
select {
case t.result <- lotteryDelivery{result: lotteryResult{Selected: i < max}}:
default:
}
}
}
func beaconPathFromRandomness(randomness, bucketKey string) string {
h := sha256.Sum256([]byte("nh-beacon-path-v1|" + randomness + "|" + bucketKey))
return beaconPaths[int(h[0])%len(beaconPaths)]
}
func deterministicUint64(seed string, counter int) uint64 {
h := sha256.Sum256([]byte(seed + "|" + big.NewInt(int64(counter)).String()))
return binary.BigEndian.Uint64(h[:8])
}
func weightedBeaconDraw(tickets []*lotteryTicket, max int, boosted string, bonusWeight int, randomness, bucketKey string) []bool {
out := make([]bool, len(tickets))
remaining := make([]int, len(tickets))
for i := range tickets {
remaining[i] = i
}
seed := "nh-beacon-draw-v1|" + randomness + "|" + bucketKey
for pick := 0; pick < max && len(remaining) > 0; pick++ {
total := 0
for _, idx := range remaining {
w := 1
if tickets[idx].path == boosted {
w = bonusWeight
}
total += w
}
if total <= 0 {
break
}
r := int(deterministicUint64(seed, pick) % uint64(total))
chosenPos := 0
for pos, idx := range remaining {
w := 1
if tickets[idx].path == boosted {
w = bonusWeight
}
if r < w {
chosenPos = pos
break
}
r -= w
}
idx := remaining[chosenPos]
out[idx] = true
remaining = append(remaining[:chosenPos], remaining[chosenPos+1:]...)
}
return out
}