Files
neural-hunt/internal/server/beacon.go
jbergner bcfbef390f
Some checks failed
release-tag / release-image (push) Failing after 2m44s
RC-7
2026-08-11 16:58:07 +02:00

170 lines
4.4 KiB
Go

package server
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
var beaconPaths = []string{"PULSE", "FLUX", "ORBIT"}
func normalizeBeaconPath(v string) string {
v = strings.ToUpper(strings.TrimSpace(v))
for _, p := range beaconPaths {
if v == p {
return p
}
}
return ""
}
type drandInfo struct {
Period int64 `json:"period"`
GenesisTime int64 `json:"genesis_time"`
}
type drandRound struct {
Round uint64 `json:"round"`
Signature string `json:"signature"`
}
type beaconReveal struct {
Source string
BeaconID string
Round uint64
Randomness string
Signature string
}
type beaconClient struct {
base string
beaconID string
hc *http.Client
mu sync.Mutex
info drandInfo
infoAt time.Time
}
func newBeaconClient() *beaconClient {
base := strings.TrimRight(strings.TrimSpace(os.Getenv("BEACON_DRAND_URL")), "/")
if base == "" {
base = "https://api.drand.sh"
}
id := strings.TrimSpace(os.Getenv("BEACON_DRAND_BEACON_ID"))
if id == "" {
id = "quicknet"
}
return &beaconClient{base: base, beaconID: id, hc: &http.Client{Timeout: 5 * time.Second}}
}
func (b *beaconClient) chainInfo(ctx context.Context) (drandInfo, error) {
b.mu.Lock()
if b.info.Period > 0 && time.Since(b.infoAt) < 6*time.Hour {
v := b.info
b.mu.Unlock()
return v, nil
}
b.mu.Unlock()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, b.base+"/v2/beacons/"+b.beaconID+"/info", nil)
if err != nil {
return drandInfo{}, err
}
resp, err := b.hc.Do(req)
if err != nil {
return drandInfo{}, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return drandInfo{}, fmt.Errorf("drand info HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var info drandInfo
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&info); err != nil {
return drandInfo{}, err
}
if info.Period <= 0 || info.GenesisTime <= 0 {
return drandInfo{}, errors.New("drand returned invalid chain info")
}
b.mu.Lock()
b.info, b.infoAt = info, time.Now()
b.mu.Unlock()
return info, nil
}
func roundTime(info drandInfo, round uint64) time.Time {
if round == 0 {
return time.Unix(info.GenesisTime, 0).UTC()
}
return time.Unix(info.GenesisTime+int64(round-1)*info.Period, 0).UTC()
}
// targetRound chooses the first beacon round that starts strictly after the
// guess window closes. That makes the beacon value unknowable while players
// are choosing PULSE/FLUX/ORBIT and submitting their ticket.
func targetRound(info drandInfo, windowEnd time.Time) uint64 {
if windowEnd.Unix() <= info.GenesisTime {
return 1
}
elapsed := windowEnd.Unix() - info.GenesisTime
return uint64(elapsed/info.Period) + 2
}
func (b *beaconClient) plan(ctx context.Context, windowEnd time.Time) (uint64, time.Time, error) {
info, err := b.chainInfo(ctx)
if err != nil {
return 0, time.Time{}, err
}
r := targetRound(info, windowEnd)
return r, roundTime(info, r), nil
}
func (b *beaconClient) reveal(ctx context.Context, round uint64) (beaconReveal, error) {
path := b.base + "/v2/beacons/" + b.beaconID + "/rounds/" + strconv.FormatUint(round, 10)
var last error
for attempt := 0; attempt < 6; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil)
if err != nil {
return beaconReveal{}, err
}
resp, err := b.hc.Do(req)
if err == nil {
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
_ = resp.Body.Close()
if readErr == nil && resp.StatusCode/100 == 2 {
var rr drandRound
if json.Unmarshal(body, &rr) == nil && rr.Round == round && rr.Signature != "" {
sig, decErr := hex.DecodeString(rr.Signature)
if decErr != nil {
return beaconReveal{}, fmt.Errorf("decode drand signature: %w", decErr)
}
h := sha256.Sum256(sig)
return beaconReveal{Source: "drand", BeaconID: b.beaconID, Round: round, Randomness: hex.EncodeToString(h[:]), Signature: rr.Signature}, nil
}
}
last = fmt.Errorf("drand round HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
} else {
last = err
}
select {
case <-ctx.Done():
return beaconReveal{}, ctx.Err()
case <-time.After(time.Duration(attempt+1) * 750 * time.Millisecond):
}
}
if last == nil {
last = errors.New("drand round unavailable")
}
return beaconReveal{}, last
}