170 lines
4.4 KiB
Go
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
|
|
}
|