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 }