285 lines
7.7 KiB
Go
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
|
|
}
|