From 48c3f752109d47eeb97c6acac5b9371d40a70f18 Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Thu, 29 Feb 2024 16:21:04 -0800 Subject: [PATCH 01/20] config, explorer: add explorer data --- config/config.go | 17 ++++++--- go.mod | 1 + go.sum | 2 + internal/explorer/explorer.go | 72 +++++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 internal/explorer/explorer.go diff --git a/config/config.go b/config/config.go index 6d9c969a..847912e0 100644 --- a/config/config.go +++ b/config/config.go @@ -19,6 +19,12 @@ type ( Address string `yaml:"address,omitempty"` } + // ExplorerData contains the configuration for using an external explorer. + ExplorerData struct { + Disable bool `yaml:"disable,omitempty"` + URL string `yaml:"url,omitempty"` + } + // RHP3 contains the configuration for the RHP3 server. RHP3 struct { TCPAddress string `yaml:"tcp,omitempty"` @@ -61,10 +67,11 @@ type ( RecoveryPhrase string `yaml:"recoveryPhrase,omitempty"` AutoOpenWebUI bool `yaml:"autoOpenWebUI,omitempty"` - HTTP HTTP `yaml:"http,omitempty"` - Consensus Consensus `yaml:"consensus,omitempty"` - RHP2 RHP2 `yaml:"rhp2,omitempty"` - RHP3 RHP3 `yaml:"rhp3,omitempty"` - Log Log `yaml:"log,omitempty"` + HTTP HTTP `yaml:"http,omitempty"` + Consensus Consensus `yaml:"consensus,omitempty"` + Explorer ExplorerData `yaml:"explorer,omitempty"` + RHP2 RHP2 `yaml:"rhp2,omitempty"` + RHP3 RHP3 `yaml:"rhp3,omitempty"` + Log Log `yaml:"log,omitempty"` } ) diff --git a/go.mod b/go.mod index 0f0f7537..2b6dd114 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/cloudflare/cloudflare-go v0.86.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/mattn/go-sqlite3 v1.14.22 + github.com/shopspring/decimal v1.3.1 gitlab.com/NebulousLabs/encoding v0.0.0-20200604091946-456c3dc907fe go.sia.tech/core v0.2.1 go.sia.tech/coreutils v0.0.3 diff --git a/go.sum b/go.sum index 52c90747..7c52506e 100644 --- a/go.sum +++ b/go.sum @@ -136,6 +136,8 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= diff --git a/internal/explorer/explorer.go b/internal/explorer/explorer.go new file mode 100644 index 00000000..e1553971 --- /dev/null +++ b/internal/explorer/explorer.go @@ -0,0 +1,72 @@ +package explorer + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/shopspring/decimal" +) + +type Explorer struct { + url string +} + +var client = &http.Client{ + Timeout: 30 * time.Second, +} + +func drainAndClose(r io.ReadCloser) { + io.Copy(io.Discard, io.LimitReader(r, 1024*1024)) + r.Close() +} + +func makeRequest(ctx context.Context, method, url string, requestBody, response any) error { + var body io.Reader + if requestBody != nil { + js, _ := json.Marshal(requestBody) + body = bytes.NewReader(js) + } + + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer drainAndClose(resp.Body) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var errorMessage string + if err := json.NewDecoder(io.LimitReader(resp.Body, 1024)).Decode(&errorMessage); err != nil { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + return errors.New(errorMessage) + } + + if response == nil { + return nil + } else if err := json.NewDecoder(resp.Body).Decode(response); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + return nil +} + +// SiacoinExchangeRate returns the exchange rate for the given currency. +func (e *Explorer) SiacoinExchangeRate(ctx context.Context, currency string) (rate decimal.Decimal, err error) { + err = makeRequest(ctx, http.MethodGet, fmt.Sprintf("%s/exchange-rate/siacoin/%s", e.url, currency), nil, &rate) + return +} + +// New returns a new Explorer client. +func New(url string) *Explorer { + return &Explorer{url: url} +} From ff8c920f8a313b32d07fd7325bdedc9eb293ba5d Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Thu, 29 Feb 2024 16:22:04 -0800 Subject: [PATCH 02/20] settings: add pinned settings, refactor constructor --- host/settings/announce_test.go | 8 +- host/settings/certs.go | 23 ++--- host/settings/options.go | 64 ++++++++++++ host/settings/pin.go | 177 +++++++++++++++++++++++++++++++++ host/settings/settings.go | 76 ++++++++++---- host/settings/settings_test.go | 8 +- 6 files changed, 321 insertions(+), 35 deletions(-) create mode 100644 host/settings/options.go create mode 100644 host/settings/pin.go diff --git a/host/settings/announce_test.go b/host/settings/announce_test.go index 3836bf7f..446a8dbe 100644 --- a/host/settings/announce_test.go +++ b/host/settings/announce_test.go @@ -42,7 +42,13 @@ func TestAutoAnnounce(t *testing.T) { } am := alerts.NewManager(webhookReporter, log.Named("alerts")) - manager, err := settings.NewConfigManager(dir, hostKey, "localhost:9882", db, node.ChainManager(), node.TPool(), node, am, log.Named("settings")) + manager, err := settings.NewConfigManager(settings.WithHostKey(hostKey), + settings.WithStore(db), + settings.WithChainManager(node.ChainManager()), + settings.WithTransactionPool(node.TPool()), + settings.WithWallet(node), + settings.WithAlertManager(am), + settings.WithLog(log.Named("settings"))) if err != nil { t.Fatal(err) } diff --git a/host/settings/certs.go b/host/settings/certs.go index b1c0932b..5f342f59 100644 --- a/host/settings/certs.go +++ b/host/settings/certs.go @@ -11,37 +11,36 @@ import ( "math/big" "net" "os" - "path/filepath" "time" ) func (m *ConfigManager) reloadCertificates() error { - certPath := filepath.Join(m.dir, "certs") - - var certificate tls.Certificate - if _, err := os.Stat(filepath.Join(certPath, "rhp3.crt")); err == nil { - certificate, err = tls.LoadX509KeyPair(filepath.Join(certPath, "rhp3.crt"), filepath.Join(certPath, "rhp3.key")) - if err != nil { - return fmt.Errorf("failed to load certificate: %w", err) - } - } else if errors.Is(err, os.ErrNotExist) { + if _, err := os.Stat(m.certKeyFilePath); errors.Is(err, os.ErrNotExist) { + // if the certificate files do not exist, create a temporary certificate addr := m.settings.NetAddress if len(addr) == 0 { addr = m.discoveredRHPAddr } addr, _, err := net.SplitHostPort(addr) if err != nil { - return fmt.Errorf("failed to parse netaddress: %w", err) + addr = "localhost" } - certificate, err = tempCertificate(addr) + certificate, err := tempCertificate(addr) if err != nil { return fmt.Errorf("failed to create temporary certificate: %w", err) } + m.rhp3WSTLS.Certificates = []tls.Certificate{certificate} + return nil } else if err != nil { return fmt.Errorf("failed to check for certificate: %w", err) } + // load the certificate from disk + certificate, err := tls.LoadX509KeyPair(m.certCertFilePath, m.certKeyFilePath) + if err != nil { + return fmt.Errorf("failed to load certificate: %w", err) + } m.rhp3WSTLS.Certificates = []tls.Certificate{certificate} return nil } diff --git a/host/settings/options.go b/host/settings/options.go new file mode 100644 index 00000000..776b0fc2 --- /dev/null +++ b/host/settings/options.go @@ -0,0 +1,64 @@ +package settings + +import ( + "go.sia.tech/core/types" + "go.uber.org/zap" +) + +type Option func(*ConfigManager) + +// WithLog sets the logger for the settings manager. +func WithLog(log *zap.Logger) Option { + return func(cm *ConfigManager) { + cm.log = log + } +} + +func WithStore(s Store) Option { + return func(cm *ConfigManager) { + cm.store = s + } +} + +func WithChainManager(cm ChainManager) Option { + return func(c *ConfigManager) { + c.cm = cm + } +} + +func WithTransactionPool(tp TransactionPool) Option { + return func(c *ConfigManager) { + c.tp = tp + } +} + +func WithWallet(w Wallet) Option { + return func(c *ConfigManager) { + c.wallet = w + } +} + +func WithAlertManager(am Alerts) Option { + return func(c *ConfigManager) { + c.a = am + } +} + +func WithHostKey(pk types.PrivateKey) Option { + return func(c *ConfigManager) { + c.hostKey = pk + } +} + +func WithRHP2Addr(addr string) Option { + return func(c *ConfigManager) { + c.discoveredRHPAddr = addr + } +} + +func WithCertificateFiles(certFilePath, keyFilePath string) Option { + return func(c *ConfigManager) { + c.certCertFilePath = certFilePath + c.certKeyFilePath = keyFilePath + } +} diff --git a/host/settings/pin.go b/host/settings/pin.go new file mode 100644 index 00000000..74216427 --- /dev/null +++ b/host/settings/pin.go @@ -0,0 +1,177 @@ +package settings + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/shopspring/decimal" + "go.sia.tech/core/types" + "go.sia.tech/hostd/internal/explorer" + "go.uber.org/zap" +) + +type ( + // A PinStore stores and retrieves pinned settings. + PinStore interface { + PinnedSettings() (PinnedSettings, error) + UpdatePinnedSettings(PinnedSettings) error + } + + // A PinManager manages the host's pinned settings and updates the host's + // settings based on the current exchange rate. + PinManager struct { + log *zap.Logger + store PinStore + explorer *explorer.Explorer + cm *ConfigManager + + frequency time.Duration + + mu sync.Mutex + rates []decimal.Decimal + pinned PinnedSettings // in-memory cache of pinned settings + } +) + +func isOverThreshold(a, b, percentage decimal.Decimal) bool { + threshold := a.Mul(percentage) + diff := a.Sub(b).Abs() + return diff.GreaterThan(threshold) +} + +func convertToCurrency(target decimal.Decimal, rate decimal.Decimal) types.Currency { + hastings := target.Div(rate).Mul(decimal.New(1, 24)).Round(0).String() + c, err := types.ParseCurrency(hastings) + if err != nil { + panic(err) + } + return c +} + +func (pm *PinManager) update(ctx context.Context) error { + var lastRate decimal.Decimal + if len(pm.rates) > 0 { + lastRate = pm.rates[len(pm.rates)-1] + } + + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + pm.mu.Lock() + currency := pm.pinned.Currency + pm.mu.Unlock() + + current, err := pm.explorer.SiacoinExchangeRate(ctx, currency) + if err != nil { + return fmt.Errorf("failed to get exchange rate: %w", err) + } else if current.IsZero() { + return fmt.Errorf("exchange rate is zero") + } + + pm.mu.Lock() + defer pm.mu.Unlock() + + maxRates := int(12 * time.Hour / pm.frequency) + pm.rates = append(pm.rates, current) + if len(pm.rates) >= maxRates { + pm.rates = pm.rates[1:] + } + + // skip updating prices if the pinned settings are zero + if pm.pinned.Storage.IsZero() && pm.pinned.Ingress.IsZero() && pm.pinned.Egress.IsZero() && pm.pinned.MaxCollateral.IsZero() { + return nil + } + + var sum decimal.Decimal + for _, r := range pm.rates { + sum = sum.Add(r) + } + avgRate := sum.Div(decimal.New(int64(len(pm.rates)), 0)) + + if !isOverThreshold(lastRate, avgRate, decimal.NewFromFloat(0.05)) { + pm.log.Debug("new rate not over threshold", zap.Stringer("current", current), zap.Stringer("average", avgRate), zap.Stringer("last", lastRate)) + return nil + } + + settings := pm.cm.Settings() + if !pm.pinned.Storage.IsZero() { + settings.StoragePrice = convertToCurrency(pm.pinned.Storage, avgRate).Div64(4320).Div64(1e12) + } + + if !pm.pinned.Ingress.IsZero() { + settings.IngressPrice = convertToCurrency(pm.pinned.Ingress, avgRate).Div64(1e12) + } + + if !pm.pinned.Egress.IsZero() { + settings.EgressPrice = convertToCurrency(pm.pinned.Egress, avgRate).Div64(1e12) + } + + if !pm.pinned.MaxCollateral.IsZero() { + settings.MaxCollateral = convertToCurrency(pm.pinned.MaxCollateral, avgRate).Div64(4320).Div64(1e12) + } + + if err := pm.cm.UpdateSettings(settings); err != nil { + return fmt.Errorf("failed to update settings: %w", err) + } + pm.log.Debug("updated prices", zap.Stringer("storage", settings.StoragePrice), zap.Stringer("ingress", settings.IngressPrice), zap.Stringer("egress", settings.EgressPrice)) + return nil +} + +// Pinned returns the host's pinned settings. +func (pm *PinManager) Pinned() PinnedSettings { + pm.mu.Lock() + defer pm.mu.Unlock() + return pm.pinned +} + +// Update updates the host's pinned settings. +func (pm *PinManager) Update(p PinnedSettings) error { + pm.mu.Lock() + if pm.pinned.Currency != p.Currency { + pm.rates = pm.rates[:0] // currency has changed, reset rates + } + pm.pinned = p + pm.mu.Unlock() + if err := pm.store.UpdatePinnedSettings(p); err != nil { + return fmt.Errorf("failed to update pinned settings: %w", err) + } else if err := pm.update(context.Background()); err != nil { + return fmt.Errorf("failed to update prices: %w", err) + } + return nil +} + +// Run starts the PinManager's update loop. +func (pm *PinManager) Run(ctx context.Context) error { + t := time.NewTicker(5 * time.Minute) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + if err := pm.update(ctx); err != nil { + pm.log.Error("failed to update prices", zap.Error(err)) + } + } + } +} + +// NewPinManager creates a new PinManager. +func NewPinManager(frequency time.Duration, store PinStore, explorer *explorer.Explorer, cm *ConfigManager, log *zap.Logger) (*PinManager, error) { + pinned, err := store.PinnedSettings() + if err != nil { + return nil, fmt.Errorf("failed to get pinned settings: %w", err) + } + + return &PinManager{ + log: log, + store: store, + explorer: explorer, + cm: cm, + + frequency: frequency, + pinned: pinned, + }, nil +} diff --git a/host/settings/settings.go b/host/settings/settings.go index 5feb5b13..60ce0442 100644 --- a/host/settings/settings.go +++ b/host/settings/settings.go @@ -2,6 +2,7 @@ package settings import ( "bytes" + "crypto/ed25519" "crypto/tls" "errors" "fmt" @@ -10,6 +11,7 @@ import ( "sync" "time" + "github.com/shopspring/decimal" "go.sia.tech/core/consensus" "go.sia.tech/core/types" "go.sia.tech/hostd/alerts" @@ -47,6 +49,20 @@ type ( LastSettingsConsensusChange() (modules.ConsensusChangeID, uint64, error) } + // PinnedSettings contains the settings that can be optionally + // pinned to an external currency. This uses an external explorer + // to retrieve the current exchange rate. + PinnedSettings struct { + Currency string `json:"currency"` + Threshold decimal.Decimal `json:"threshold"` + + Storage decimal.Decimal `json:"storage"` + Ingress decimal.Decimal `json:"ingress"` + Egress decimal.Decimal `json:"egress"` + + MaxCollateral decimal.Decimal `json:"maxCollateral"` + } + // Settings contains configuration options for the host. Settings struct { // Host settings @@ -113,10 +129,12 @@ type ( // A ConfigManager manages the host's current configuration ConfigManager struct { - dir string hostKey types.PrivateKey discoveredRHPAddr string + certKeyFilePath string + certCertFilePath string + store Store a Alerts log *zap.Logger @@ -125,10 +143,11 @@ type ( tp TransactionPool wallet Wallet - mu sync.Mutex // guards the following fields - settings Settings // in-memory cache of the host's settings - scanHeight uint64 // track the last block height that was scanned for announcements - lastAnnounceAttempt uint64 // debounce announcement transactions + mu sync.Mutex // guards the following fields + settings Settings // in-memory cache of the host's settings + pinned PinnedSettings // in-memory cache of the host's pinned settings + scanHeight uint64 // track the last block height that was scanned for announcements + lastAnnounceAttempt uint64 // debounce announcement transactions ingressLimit *rate.Limiter egressLimit *rate.Limiter @@ -242,6 +261,22 @@ func (m *ConfigManager) Settings() Settings { return m.settings } +// Pinned returns the pinned settings +func (m *ConfigManager) Pinned() PinnedSettings { + m.mu.Lock() + defer m.mu.Unlock() + return m.pinned +} + +// UpdatePinned updates the pinned settings. If the exchange rate manager is not +// initialized, an error is returned. +func (m *ConfigManager) UpdatePinned(p PinnedSettings) error { + m.mu.Lock() + m.pinned = p + m.mu.Unlock() + return nil +} + // BandwidthLimiters returns the rate limiters for all traffic func (m *ConfigManager) BandwidthLimiters() (ingress, egress *rate.Limiter) { return m.ingressLimit, m.egressLimit @@ -275,19 +310,10 @@ func createAnnouncement(priv types.PrivateKey, netaddress string) []byte { } // NewConfigManager initializes a new config manager -func NewConfigManager(dir string, hostKey types.PrivateKey, rhp2Addr string, store Store, cm ChainManager, tp TransactionPool, w Wallet, a Alerts, log *zap.Logger) (*ConfigManager, error) { +func NewConfigManager(opts ...Option) (*ConfigManager, error) { m := &ConfigManager{ - dir: dir, - hostKey: hostKey, - discoveredRHPAddr: rhp2Addr, - - store: store, - a: a, - log: log, - cm: cm, - tp: tp, - wallet: w, - tg: threadgroup.New(), + log: zap.NewNop(), + tg: threadgroup.New(), // initialize the rate limiters ingressLimit: rate.NewLimiter(rate.Inf, defaultBurstSize), @@ -297,13 +323,21 @@ func NewConfigManager(dir string, hostKey types.PrivateKey, rhp2Addr string, sto rhp3WSTLS: &tls.Config{}, } + for _, opt := range opts { + opt(m) + } + + if len(m.hostKey) != ed25519.PrivateKeySize { + panic("host key invalid") + } + if err := m.reloadCertificates(); err != nil { return nil, fmt.Errorf("failed to load rhp3 WebSocket certificates: %w", err) } settings, err := m.store.Settings() if errors.Is(err, ErrNoSettings) { - if err := store.UpdateSettings(DefaultSettings); err != nil { + if err := m.store.UpdateSettings(DefaultSettings); err != nil { return nil, fmt.Errorf("failed to initialize settings: %w", err) } settings = DefaultSettings // use the default settings @@ -319,13 +353,13 @@ func NewConfigManager(dir string, hostKey types.PrivateKey, rhp2Addr string, sto go func() { // subscribe to consensus changes - err := cm.Subscribe(m, lastChange, m.tg.Done()) + err := m.cm.Subscribe(m, lastChange, m.tg.Done()) if errors.Is(err, chain.ErrInvalidChangeID) { m.log.Warn("rescanning blockchain due to unknown consensus change ID") // reset change ID and subscribe again - if err := store.RevertLastAnnouncement(); err != nil { + if err := m.store.RevertLastAnnouncement(); err != nil { m.log.Fatal("failed to reset wallet", zap.Error(err)) - } else if err = cm.Subscribe(m, modules.ConsensusChangeBeginning, m.tg.Done()); err != nil { + } else if err = m.cm.Subscribe(m, modules.ConsensusChangeBeginning, m.tg.Done()); err != nil { m.log.Fatal("failed to reset consensus change subscription", zap.Error(err)) } } else if err != nil && !strings.Contains(err.Error(), "ThreadGroup already stopped") { diff --git a/host/settings/settings_test.go b/host/settings/settings_test.go index edfa66b1..d5d6141d 100644 --- a/host/settings/settings_test.go +++ b/host/settings/settings_test.go @@ -37,7 +37,13 @@ func TestSettings(t *testing.T) { } am := alerts.NewManager(webhookReporter, log.Named("alerts")) - manager, err := settings.NewConfigManager(dir, hostKey, "localhost:9882", db, node.ChainManager(), node.TPool(), node, am, log.Named("settings")) + manager, err := settings.NewConfigManager(settings.WithHostKey(hostKey), + settings.WithStore(db), + settings.WithChainManager(node.ChainManager()), + settings.WithTransactionPool(node.TPool()), + settings.WithWallet(node), + settings.WithAlertManager(am), + settings.WithLog(log.Named("settings"))) if err != nil { t.Fatal(err) } From f300810ced7813ec1fef751e553ab5f7673203d4 Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Thu, 29 Feb 2024 16:22:17 -0800 Subject: [PATCH 03/20] sqlite: add pinned settings table --- persist/sqlite/init.sql | 9 +++++++++ persist/sqlite/migrations.go | 14 ++++++++++++++ persist/sqlite/settings.go | 23 +++++++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/persist/sqlite/init.sql b/persist/sqlite/init.sql index 95fc6314..38531948 100644 --- a/persist/sqlite/init.sql +++ b/persist/sqlite/init.sql @@ -196,6 +196,15 @@ CREATE TABLE host_settings ( sector_cache_size INTEGER NOT NULL DEFAULT 0 ); +CREATE TABLE host_pinned_settings ( + id INTEGER PRIMARY KEY NOT NULL DEFAULT 0 CHECK (id = 0), -- enforce a single row + currency TEXT NOT NULL, + storage_price REAL NOT NULL, + ingress_price REAL NOT NULL, + egress_price REAL NOT NULL, + max_collateral REAL NOT NULL +); + CREATE TABLE webhooks ( id INTEGER PRIMARY KEY, callback_url TEXT UNIQUE NOT NULL, diff --git a/persist/sqlite/migrations.go b/persist/sqlite/migrations.go index 666f6376..725be36f 100644 --- a/persist/sqlite/migrations.go +++ b/persist/sqlite/migrations.go @@ -10,6 +10,19 @@ import ( "go.uber.org/zap" ) +// migrateVersion26 creates the host_pinned_settings table. +func migrateVersion26(tx txn, _ *zap.Logger) error { + _, err := tx.Exec(`CREATE TABLE host_pinned_settings ( + id INTEGER PRIMARY KEY NOT NULL DEFAULT 0 CHECK (id = 0), -- enforce a single row + currency TEXT NOT NULL, + storage_price REAL NOT NULL, + ingress_price REAL NOT NULL, + egress_price REAL NOT NULL, + max_collateral REAL NOT NULL + );`) + return err +} + // migrateVersion25 recalculates the contract and physical sectors metrics func migrateVersion25(tx txn, log *zap.Logger) error { // recalculate the contract sectors metric @@ -734,4 +747,5 @@ var migrations = []func(tx txn, log *zap.Logger) error{ migrateVersion23, migrateVersion24, migrateVersion25, + migrateVersion26, } diff --git a/persist/sqlite/settings.go b/persist/sqlite/settings.go index 5cae9322..b683f5b3 100644 --- a/persist/sqlite/settings.go +++ b/persist/sqlite/settings.go @@ -14,6 +14,29 @@ import ( "go.uber.org/zap" ) +// PinnedSettings returns the host's pinned settings. +func (s *Store) PinnedSettings() (pinned settings.PinnedSettings, err error) { + const query = `SELECT currency, storage_price, ingress_price, egress_price, max_collateral +FROM host_pinned_settings;` + + err = s.queryRow(query).Scan(&pinned.Currency, &pinned.Storage, &pinned.Ingress, &pinned.Egress, &pinned.MaxCollateral) + if errors.Is(err, sql.ErrNoRows) { + return settings.PinnedSettings{}, nil + } + return +} + +// UpdatePinnedSettings updates the host's pinned settings. +func (s *Store) UpdatePinnedSettings(p settings.PinnedSettings) error { + const query = `INSERT INTO host_pinned_settings (id, currency, storage_price, ingress_price, egress_price, max_collateral) VALUES (0, $1, $2, $3, $4, $5) +ON CONFLICT (id) DO UPDATE SET currency=EXCLUDED.currency, storage_price=EXCLUDED.storage_price, ingress_price=EXCLUDED.ingress_price, egress_price=EXCLUDED.egress_price, max_collateral=EXCLUDED.max_collateral +RETURNING id;` + + var dummyID int64 + err := s.queryRow(query, p.Currency, p.Storage, p.Ingress, p.Egress, p.MaxCollateral).Scan(&dummyID) + return err +} + // Settings returns the current host settings. func (s *Store) Settings() (config settings.Settings, err error) { var dyndnsBuf []byte From 71ec6edb4eae708128393032af4ae3b78efe7d2d Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Thu, 29 Feb 2024 16:22:29 -0800 Subject: [PATCH 04/20] api,cmd,test: add pinned settings --- api/api.go | 137 +++++++++++++++++++++--------------------- api/endpoints.go | 46 ++++++++++---- api/options.go | 90 +++++++++++++++++++++++++++ api/rhpsessions.go | 3 +- api/volumes.go | 2 +- cmd/hostd/config.go | 5 -- cmd/hostd/main.go | 35 +++++++++-- cmd/hostd/node.go | 26 +++++++- internal/test/host.go | 8 ++- 9 files changed, 255 insertions(+), 97 deletions(-) create mode 100644 api/options.go diff --git a/api/api.go b/api/api.go index 0f745d39..eb0fd673 100644 --- a/api/api.go +++ b/api/api.go @@ -46,6 +46,12 @@ type ( UpdateDDNS(force bool) error } + // PinnedSettings updates and retrieves the host's pinned settings + PinnedSettings interface { + Update(p settings.PinnedSettings) error + Pinned() settings.PinnedSettings + } + // A MetricManager retrieves metrics related to the host MetricManager interface { // PeriodMetrics returns metrics for n periods starting at start. @@ -149,6 +155,7 @@ type ( wallet Wallet metrics MetricManager settings Settings + pinned PinnedSettings sessions RHPSessionReporter volumeJobs volumeJobs @@ -157,92 +164,84 @@ type ( ) // NewServer initializes the API -func NewServer(name string, hostKey types.PublicKey, a Alerts, wh WebHooks, g Syncer, chain ChainManager, tp TPool, cm ContractManager, am AccountManager, vm VolumeManager, rsr RHPSessionReporter, m MetricManager, s Settings, w Wallet, log *zap.Logger) http.Handler { - api := &api{ +func NewServer(name string, hostKey types.PublicKey, opts ...ServerOption) http.Handler { + a := &api{ hostKey: hostKey, name: name, - - alerts: a, - webhooks: wh, - syncer: g, - chain: chain, - tpool: tp, - contracts: cm, - accounts: am, - volumes: vm, - metrics: m, - settings: s, - wallet: w, - sessions: rsr, - log: log, - - checks: integrityCheckJobs{ - contracts: cm, - checks: make(map[types.FileContractID]IntegrityCheckResult), - }, - volumeJobs: volumeJobs{ - volumes: vm, - jobs: make(map[int64]context.CancelFunc), - }, + log: zap.NewNop(), + } + for _, opt := range opts { + opt(a) } + a.checks = integrityCheckJobs{ + contracts: a.contracts, + checks: make(map[types.FileContractID]IntegrityCheckResult), + } + a.volumeJobs = volumeJobs{ + volumes: a.volumes, + jobs: make(map[int64]context.CancelFunc), + } + return jape.Mux(map[string]jape.Handler{ // state endpoints - "GET /state/host": api.handleGETHostState, - "GET /state/consensus": api.handleGETConsensusState, + "GET /state/host": a.handleGETHostState, + "GET /state/consensus": a.handleGETConsensusState, // gateway endpoints - "GET /syncer/address": api.handleGETSyncerAddr, - "GET /syncer/peers": api.handleGETSyncerPeers, - "PUT /syncer/peers": api.handlePUTSyncerPeer, - "DELETE /syncer/peers/:address": api.handleDeleteSyncerPeer, + "GET /syncer/address": a.handleGETSyncerAddr, + "GET /syncer/peers": a.handleGETSyncerPeers, + "PUT /syncer/peers": a.handlePUTSyncerPeer, + "DELETE /syncer/peers/:address": a.handleDeleteSyncerPeer, // alerts endpoints - "GET /alerts": api.handleGETAlerts, - "POST /alerts/dismiss": api.handlePOSTAlertsDismiss, + "GET /alerts": a.handleGETAlerts, + "POST /alerts/dismiss": a.handlePOSTAlertsDismiss, // settings endpoints - "GET /settings": api.handleGETSettings, - "PATCH /settings": api.handlePATCHSettings, - "POST /settings/announce": api.handlePOSTAnnounce, - "PUT /settings/ddns/update": api.handlePUTDDNSUpdate, + "GET /settings": a.handleGETSettings, + "PATCH /settings": a.handlePATCHSettings, + "POST /settings/announce": a.handlePOSTAnnounce, + "PUT /settings/ddns/update": a.handlePUTDDNSUpdate, + "GET /settings/pinned": a.handleGETPinnedSettings, + "PUT /settings/pinned": a.handlePUTPinnedSettings, // metrics endpoints - "GET /metrics": api.handleGETMetrics, - "GET /metrics/:period": api.handleGETPeriodMetrics, + "GET /metrics": a.handleGETMetrics, + "GET /metrics/:period": a.handleGETPeriodMetrics, // contract endpoints - "POST /contracts": api.handlePostContracts, - "GET /contracts/:id": api.handleGETContract, - "GET /contracts/:id/integrity": api.handleGETContractCheck, - "PUT /contracts/:id/integrity": api.handlePUTContractCheck, - "DELETE /contracts/:id/integrity": api.handleDeleteContractCheck, + "POST /contracts": a.handlePostContracts, + "GET /contracts/:id": a.handleGETContract, + "GET /contracts/:id/integrity": a.handleGETContractCheck, + "PUT /contracts/:id/integrity": a.handlePUTContractCheck, + "DELETE /contracts/:id/integrity": a.handleDeleteContractCheck, // account endpoints - "GET /accounts": api.handleGETAccounts, - "GET /accounts/:account/funding": api.handleGETAccountFunding, + "GET /accounts": a.handleGETAccounts, + "GET /accounts/:account/funding": a.handleGETAccountFunding, // sector endpoints - "DELETE /sectors/:root": api.handleDeleteSector, - "GET /sectors/:root/verify": api.handleGETVerifySector, + "DELETE /sectors/:root": a.handleDeleteSector, + "GET /sectors/:root/verify": a.handleGETVerifySector, // volume endpoints - "GET /volumes": api.handleGETVolumes, - "POST /volumes": api.handlePOSTVolume, - "GET /volumes/:id": api.handleGETVolume, - "PUT /volumes/:id": api.handlePUTVolume, - "DELETE /volumes/:id": api.handleDeleteVolume, - "DELETE /volumes/:id/cancel": api.handleDELETEVolumeCancelOp, - "PUT /volumes/:id/resize": api.handlePUTVolumeResize, + "GET /volumes": a.handleGETVolumes, + "POST /volumes": a.handlePOSTVolume, + "GET /volumes/:id": a.handleGETVolume, + "PUT /volumes/:id": a.handlePUTVolume, + "DELETE /volumes/:id": a.handleDeleteVolume, + "DELETE /volumes/:id/cancel": a.handleDELETEVolumeCancelOp, + "PUT /volumes/:id/resize": a.handlePUTVolumeResize, // session endpoints - "GET /sessions": api.handleGETSessions, - "GET /sessions/subscribe": api.handleGETSessionsSubscribe, + "GET /sessions": a.handleGETSessions, + "GET /sessions/subscribe": a.handleGETSessionsSubscribe, // tpool endpoints - "GET /tpool/fee": api.handleGETTPoolFee, + "GET /tpool/fee": a.handleGETTPoolFee, // wallet endpoints - "GET /wallet": api.handleGETWallet, - "GET /wallet/transactions": api.handleGETWalletTransactions, - "GET /wallet/pending": api.handleGETWalletPending, - "POST /wallet/send": api.handlePOSTWalletSend, + "GET /wallet": a.handleGETWallet, + "GET /wallet/transactions": a.handleGETWalletTransactions, + "GET /wallet/pending": a.handleGETWalletPending, + "POST /wallet/send": a.handlePOSTWalletSend, // system endpoints - "GET /system/dir": api.handleGETSystemDir, - "PUT /system/dir": api.handlePUTSystemDir, + "GET /system/dir": a.handleGETSystemDir, + "PUT /system/dir": a.handlePUTSystemDir, // webhook endpoints - "GET /webhooks": api.handleGETWebhooks, - "POST /webhooks": api.handlePOSTWebhooks, - "PUT /webhooks/:id": api.handlePUTWebhooks, - "POST /webhooks/:id/test": api.handlePOSTWebhooksTest, - "DELETE /webhooks/:id": api.handleDELETEWebhooks, + "GET /webhooks": a.handleGETWebhooks, + "POST /webhooks": a.handlePOSTWebhooks, + "PUT /webhooks/:id": a.handlePUTWebhooks, + "POST /webhooks/:id/test": a.handlePOSTWebhooksTest, + "DELETE /webhooks/:id": a.handleDELETEWebhooks, }) } diff --git a/api/endpoints.go b/api/endpoints.go index f6482266..022ea5b0 100644 --- a/api/endpoints.go +++ b/api/endpoints.go @@ -39,7 +39,7 @@ func (a *api) checkServerError(c jape.Context, context string, err error) bool { return err == nil } -func (a *api) writeResponse(c jape.Context, code int, resp any) { +func (a *api) writeResponse(c jape.Context, resp any) { var responseFormat string if err := c.DecodeForm("response", &responseFormat); err != nil { return @@ -74,7 +74,7 @@ func (a *api) handleGETHostState(c jape.Context) { return } - a.writeResponse(c, http.StatusOK, HostState{ + a.writeResponse(c, HostState{ Name: a.name, PublicKey: a.hostKey, WalletAddress: a.wallet.Address(), @@ -91,14 +91,14 @@ func (a *api) handleGETHostState(c jape.Context) { } func (a *api) handleGETConsensusState(c jape.Context) { - a.writeResponse(c, http.StatusOK, ConsensusState{ + a.writeResponse(c, ConsensusState{ Synced: a.chain.Synced(), ChainIndex: a.chain.TipState().Index, }) } func (a *api) handleGETSyncerAddr(c jape.Context) { - a.writeResponse(c, http.StatusOK, SyncerAddrResp(a.syncer.Address())) + a.writeResponse(c, SyncerAddrResp(a.syncer.Address())) } func (a *api) handleGETSyncerPeers(c jape.Context) { @@ -110,7 +110,7 @@ func (a *api) handleGETSyncerPeers(c jape.Context) { Version: peer.Version, } } - a.writeResponse(c, http.StatusOK, PeerResp(peers)) + a.writeResponse(c, PeerResp(peers)) } func (a *api) handlePUTSyncerPeer(c jape.Context) { @@ -132,7 +132,7 @@ func (a *api) handleDeleteSyncerPeer(c jape.Context) { } func (a *api) handleGETAlerts(c jape.Context) { - a.writeResponse(c, http.StatusOK, AlertResp(a.alerts.Active())) + a.writeResponse(c, AlertResp(a.alerts.Active())) } func (a *api) handlePOSTAlertsDismiss(c jape.Context) { @@ -153,7 +153,7 @@ func (a *api) handlePOSTAnnounce(c jape.Context) { func (a *api) handleGETSettings(c jape.Context) { hs := HostSettings(a.settings.Settings()) - a.writeResponse(c, http.StatusOK, hs) + a.writeResponse(c, hs) } func (a *api) handlePATCHSettings(c jape.Context) { @@ -199,6 +199,28 @@ func (a *api) handlePATCHSettings(c jape.Context) { c.Encode(a.settings.Settings()) } +func (a *api) handleGETPinnedSettings(c jape.Context) { + if a.pinned == nil { + c.Error(errors.New("pinned settings disabled"), http.StatusNotFound) + return + } + c.Encode(a.pinned.Pinned()) +} + +func (a *api) handlePUTPinnedSettings(c jape.Context) { + if a.pinned == nil { + c.Error(errors.New("pinned settings disabled"), http.StatusNotFound) + return + } + + var req settings.PinnedSettings + if err := c.Decode(&req); err != nil { + return + } + + a.checkServerError(c, "failed to update pinned settings", a.pinned.Update(req)) +} + func (a *api) handlePUTDDNSUpdate(c jape.Context) { err := a.settings.UpdateDDNS(true) a.checkServerError(c, "failed to update dynamic DNS", err) @@ -217,7 +239,7 @@ func (a *api) handleGETMetrics(c jape.Context) { return } - a.writeResponse(c, http.StatusOK, Metrics(metrics)) + a.writeResponse(c, Metrics(metrics)) } func (a *api) handleGETPeriodMetrics(c jape.Context) { @@ -370,7 +392,7 @@ func (a *api) handleGETWallet(c jape.Context) { if !a.checkServerError(c, "failed to get wallet", err) { return } - a.writeResponse(c, http.StatusOK, WalletResponse{ + a.writeResponse(c, WalletResponse{ ScanHeight: a.wallet.ScanHeight(), Address: a.wallet.Address(), Spendable: spendable, @@ -387,7 +409,7 @@ func (a *api) handleGETWalletTransactions(c jape.Context) { return } - a.writeResponse(c, http.StatusOK, WalletTransactionsResp(transactions)) + a.writeResponse(c, WalletTransactionsResp(transactions)) } func (a *api) handleGETWalletPending(c jape.Context) { @@ -395,7 +417,7 @@ func (a *api) handleGETWalletPending(c jape.Context) { if !a.checkServerError(c, "failed to get wallet pending", err) { return } - a.writeResponse(c, http.StatusOK, WalletPendingResp(pending)) + a.writeResponse(c, WalletPendingResp(pending)) } func (a *api) handlePOSTWalletSend(c jape.Context) { @@ -534,7 +556,7 @@ func (a *api) handlePUTSystemDir(c jape.Context) { } func (a *api) handleGETTPoolFee(c jape.Context) { - a.writeResponse(c, http.StatusOK, TPoolResp(a.tpool.RecommendedFee())) + a.writeResponse(c, TPoolResp(a.tpool.RecommendedFee())) } func (a *api) handleGETAccounts(c jape.Context) { diff --git a/api/options.go b/api/options.go new file mode 100644 index 00000000..5732d1a7 --- /dev/null +++ b/api/options.go @@ -0,0 +1,90 @@ +package api + +import "go.uber.org/zap" + +// ServerOption is a functional option to configure an API server. +type ServerOption func(*api) + +func ServerWithAlerts(al Alerts) ServerOption { + return func(a *api) { + a.alerts = al + } +} + +func ServerWithWebHooks(w WebHooks) ServerOption { + return func(a *api) { + a.webhooks = w + } +} + +func ServerWithSyncer(g Syncer) ServerOption { + return func(a *api) { + a.syncer = g + } +} + +func ServerWithChainManager(chain ChainManager) ServerOption { + return func(a *api) { + a.chain = chain + } +} + +func ServerWithTransactionPool(tp TPool) ServerOption { + return func(a *api) { + a.tpool = tp + } +} + +func ServerWithContractManager(cm ContractManager) ServerOption { + return func(a *api) { + a.contracts = cm + } +} + +func ServerWithAccountManager(am AccountManager) ServerOption { + return func(a *api) { + a.accounts = am + } +} + +func ServerWithVolumeManager(vm VolumeManager) ServerOption { + return func(a *api) { + a.volumes = vm + } +} + +func ServerWithMetricManager(m MetricManager) ServerOption { + return func(a *api) { + a.metrics = m + } +} + +func ServerWithPinnedSettings(p PinnedSettings) ServerOption { + return func(a *api) { + a.pinned = p + } +} + +func ServerWithSettings(s Settings) ServerOption { + return func(a *api) { + a.settings = s + } +} + +func ServerWithRHPSessionReporter(rsr RHPSessionReporter) ServerOption { + return func(a *api) { + a.sessions = rsr + } +} + +func ServerWithWallet(w Wallet) ServerOption { + return func(a *api) { + a.wallet = w + } +} + +func ServerWithLogger(log *zap.Logger) ServerOption { + return func(a *api) { + a.log = log + } +} diff --git a/api/rhpsessions.go b/api/rhpsessions.go index 911615b1..0582e057 100644 --- a/api/rhpsessions.go +++ b/api/rhpsessions.go @@ -3,7 +3,6 @@ package api import ( "context" "encoding/json" - "net/http" "go.sia.tech/hostd/rhp" "go.sia.tech/jape" @@ -24,7 +23,7 @@ func (rs *rhpSessionSubscriber) ReceiveSessionEvent(event rhp.SessionEvent) { } func (a *api) handleGETSessions(c jape.Context) { - a.writeResponse(c, http.StatusOK, SessionResp(a.sessions.Active())) + a.writeResponse(c, SessionResp(a.sessions.Active())) } func (a *api) handleGETSessionsSubscribe(c jape.Context) { diff --git a/api/volumes.go b/api/volumes.go index 8d6b0034..11104e4e 100644 --- a/api/volumes.go +++ b/api/volumes.go @@ -133,7 +133,7 @@ func (a *api) handleGETVolumes(c jape.Context) { for _, volume := range volumes { jsonVolumes = append(jsonVolumes, toJSONVolume(volume)) } - a.writeResponse(c, http.StatusOK, VolumeResp(jsonVolumes)) + a.writeResponse(c, VolumeResp(jsonVolumes)) } func (a *api) handlePOSTVolume(c jape.Context) { diff --git a/cmd/hostd/config.go b/cmd/hostd/config.go index 5c9e8216..83efdbf0 100644 --- a/cmd/hostd/config.go +++ b/cmd/hostd/config.go @@ -108,12 +108,7 @@ func setAPIPassword() { fmt.Println("Please choose a password to unlock hostd.") fmt.Println("This password will be required to access the admin UI in your web browser.") fmt.Println("(The password must be at least 4 characters.)") - var err error cfg.HTTP.Password = readPasswordInput("Enter password") - if err != nil { - stdoutFatalError("Could not read password:" + err.Error()) - } - if len(cfg.HTTP.Password) >= 4 { break } diff --git a/cmd/hostd/main.go b/cmd/hostd/main.go index d36bebb4..26571a35 100644 --- a/cmd/hostd/main.go +++ b/cmd/hostd/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "errors" "flag" "fmt" @@ -38,6 +39,9 @@ var ( Address: defaultAPIAddr, Password: os.Getenv(apiPasswordEnvVariable), }, + Explorer: config.ExplorerData{ + URL: "https://api.siascan.com", + }, Consensus: config.Consensus{ GatewayAddress: defaultGatewayAddr, Bootstrap: true, @@ -227,6 +231,9 @@ func main() { return } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + // check that the API password is set if cfg.HTTP.Password == "" { if disableStdin { @@ -350,16 +357,36 @@ func main() { } defer rhp3WSListener.Close() - node, hostKey, err := newNode(walletKey, log) + node, hostKey, err := newNode(ctx, walletKey, log) if err != nil { log.Fatal("failed to create node", zap.Error(err)) } defer node.Close() + opts := []api.ServerOption{ + api.ServerWithAlerts(node.a), + api.ServerWithWebHooks(node.wh), + api.ServerWithSyncer(node.g), + api.ServerWithChainManager(node.cm), + api.ServerWithTransactionPool(node.tp), + api.ServerWithContractManager(node.contracts), + api.ServerWithAccountManager(node.accounts), + api.ServerWithVolumeManager(node.storage), + api.ServerWithRHPSessionReporter(node.sessions), + api.ServerWithMetricManager(node.metrics), + api.ServerWithSettings(node.settings), + api.ServerWithWallet(node.w), + api.ServerWithLogger(log.Named("api")), + } + if node.pinned != nil { + // pinner should be nil if explorer data is disabled + opts = append(opts, api.ServerWithPinnedSettings(node.pinned)) + } + auth := jape.BasicAuth(cfg.HTTP.Password) web := http.Server{ Handler: webRouter{ - api: auth(api.NewServer(cfg.Name, hostKey.PublicKey(), node.a, node.wh, node.g, node.cm, node.tp, node.contracts, node.accounts, node.storage, node.sessions, node.metrics, node.settings, node.w, log.Named("api"))), + api: auth(api.NewServer(cfg.Name, hostKey.PublicKey(), opts...)), ui: hostd.Handler(), }, ReadTimeout: 30 * time.Second, @@ -400,9 +427,7 @@ func main() { } } - signalCh := make(chan os.Signal, 1) - signal.Notify(signalCh, os.Interrupt, syscall.SIGTERM) - <-signalCh + <-ctx.Done() log.Info("shutting down...") time.AfterFunc(5*time.Minute, func() { log.Fatal("failed to shut down within 5 minutes") diff --git a/cmd/hostd/node.go b/cmd/hostd/node.go index 0158febd..554f1f25 100644 --- a/cmd/hostd/node.go +++ b/cmd/hostd/node.go @@ -1,11 +1,13 @@ package main import ( + "context" "fmt" "net" "os" "path/filepath" "strings" + "time" "go.sia.tech/core/types" "go.sia.tech/hostd/alerts" @@ -16,6 +18,7 @@ import ( "go.sia.tech/hostd/host/settings" "go.sia.tech/hostd/host/storage" "go.sia.tech/hostd/internal/chain" + "go.sia.tech/hostd/internal/explorer" "go.sia.tech/hostd/persist/sqlite" "go.sia.tech/hostd/rhp" rhp2 "go.sia.tech/hostd/rhp/v2" @@ -40,6 +43,7 @@ type node struct { metrics *metrics.MetricManager settings *settings.ConfigManager + pinned *settings.PinManager accounts *accounts.AccountManager contracts *contracts.ContractManager registry *registry.Manager @@ -85,7 +89,7 @@ func startRHP3(l net.Listener, hostKey types.PrivateKey, cs rhp3.ChainManager, t return rhp3, nil } -func newNode(walletKey types.PrivateKey, logger *zap.Logger) (*node, types.PrivateKey, error) { +func newNode(ctx context.Context, walletKey types.PrivateKey, logger *zap.Logger) (*node, types.PrivateKey, error) { gatewayDir := filepath.Join(cfg.Directory, "gateway") if err := os.MkdirAll(gatewayDir, 0700); err != nil { return nil, types.PrivateKey{}, fmt.Errorf("failed to create gateway dir: %w", err) @@ -170,11 +174,28 @@ func newNode(walletKey types.PrivateKey, logger *zap.Logger) (*node, types.Priva logger.Debug("discovered address", zap.String("addr", discoveredAddr)) am := alerts.NewManager(webhookReporter, logger.Named("alerts")) - sr, err := settings.NewConfigManager(cfg.Directory, hostKey, discoveredAddr, db, cm, tp, w, am, logger.Named("settings")) + sr, err := settings.NewConfigManager(settings.WithHostKey(hostKey), + settings.WithStore(db), + settings.WithChainManager(cm), + settings.WithTransactionPool(tp), + settings.WithWallet(w), + settings.WithAlertManager(am), + settings.WithLog(logger.Named("settings"))) if err != nil { return nil, types.PrivateKey{}, fmt.Errorf("failed to create settings manager: %w", err) } + var pm *settings.PinManager + if !cfg.Explorer.Disable { + ex := explorer.New(cfg.Explorer.URL) + + pm, err = settings.NewPinManager(5*time.Minute, db, ex, sr, logger.Named("pin")) + if err != nil { + return nil, types.PrivateKey{}, fmt.Errorf("failed to create pin manager: %w", err) + } + go pm.Run(ctx) + } + accountManager := accounts.NewManager(db, sr) sm, err := storage.NewVolumeManager(db, am, cm, logger.Named("volumes"), sr.Settings().SectorCacheSize) @@ -212,6 +233,7 @@ func newNode(walletKey types.PrivateKey, logger *zap.Logger) (*node, types.Priva metrics: metrics.NewManager(db), settings: sr, + pinned: pm, accounts: accountManager, contracts: contractManager, storage: sm, diff --git a/internal/test/host.go b/internal/test/host.go index c6f128ea..3677ec80 100644 --- a/internal/test/host.go +++ b/internal/test/host.go @@ -225,7 +225,13 @@ func NewEmptyHost(privKey types.PrivateKey, dir string, node *Node, log *zap.Log return nil, fmt.Errorf("failed to create rhp2 listener: %w", err) } - settings, err := settings.NewConfigManager(dir, privKey, rhp2Listener.Addr().String(), db, node.cm, node.tp, wallet, am, log.Named("settings")) + settings, err := settings.NewConfigManager(settings.WithHostKey(privKey), + settings.WithStore(db), + settings.WithChainManager(node.ChainManager()), + settings.WithTransactionPool(node.TPool()), + settings.WithWallet(wallet), + settings.WithAlertManager(am), + settings.WithLog(log.Named("settings"))) if err != nil { return nil, fmt.Errorf("failed to create settings manager: %w", err) } From 40ed3d2f9fed07f0777b2ab5b8fc55f8fd861624 Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Thu, 29 Feb 2024 16:37:05 -0800 Subject: [PATCH 05/20] setting,sqlite: honor threshold --- host/settings/pin.go | 17 +++++++---------- persist/sqlite/contracts.go | 31 ------------------------------- persist/sqlite/init.sql | 1 + persist/sqlite/settings.go | 10 +++++----- 4 files changed, 13 insertions(+), 46 deletions(-) diff --git a/host/settings/pin.go b/host/settings/pin.go index 74216427..68899432 100644 --- a/host/settings/pin.go +++ b/host/settings/pin.go @@ -29,9 +29,10 @@ type ( frequency time.Duration - mu sync.Mutex - rates []decimal.Decimal - pinned PinnedSettings // in-memory cache of pinned settings + mu sync.Mutex + rates []decimal.Decimal + lastRate decimal.Decimal + pinned PinnedSettings // in-memory cache of pinned settings } ) @@ -51,11 +52,6 @@ func convertToCurrency(target decimal.Decimal, rate decimal.Decimal) types.Curre } func (pm *PinManager) update(ctx context.Context) error { - var lastRate decimal.Decimal - if len(pm.rates) > 0 { - lastRate = pm.rates[len(pm.rates)-1] - } - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() @@ -90,10 +86,11 @@ func (pm *PinManager) update(ctx context.Context) error { } avgRate := sum.Div(decimal.New(int64(len(pm.rates)), 0)) - if !isOverThreshold(lastRate, avgRate, decimal.NewFromFloat(0.05)) { - pm.log.Debug("new rate not over threshold", zap.Stringer("current", current), zap.Stringer("average", avgRate), zap.Stringer("last", lastRate)) + if !isOverThreshold(pm.lastRate, avgRate, pm.pinned.Threshold) { + pm.log.Debug("new rate not over threshold", zap.Stringer("current", current), zap.Stringer("average", avgRate), zap.Stringer("last", pm.lastRate)) return nil } + pm.lastRate = avgRate settings := pm.cm.Settings() if !pm.pinned.Storage.IsZero() { diff --git a/persist/sqlite/contracts.go b/persist/sqlite/contracts.go index e4882f91..1572a61c 100644 --- a/persist/sqlite/contracts.go +++ b/persist/sqlite/contracts.go @@ -26,12 +26,6 @@ type ( Action string } - contractSectorRef struct { - ID int64 - SectorID int64 - ContractID types.FileContractID - } - contractSectorRootRef struct { dbID int64 sectorID int64 @@ -1206,36 +1200,11 @@ func setContractStatus(tx txn, id types.FileContractID, status contracts.Contrac return nil } -func scanContractSectorRef(s scanner) (ref contractSectorRef, err error) { - err = s.Scan(&ref.ID, (*sqlHash256)(&ref.ContractID), &ref.SectorID) - return -} - func scanContractSectorRootRef(s scanner) (ref contractSectorRootRef, err error) { err = s.Scan(&ref.dbID, &ref.sectorID, (*sqlHash256)(&ref.root)) return } -func expiredContractSectors(tx txn, height uint64, batchSize int64) (sectors []contractSectorRef, _ error) { - const query = `SELECT csr.id, c.contract_id, csr.sector_id FROM contract_sector_roots csr -INNER JOIN contracts c ON (csr.contract_id=c.id) --- past proof window or not confirmed and past the rebroadcast height -WHERE c.window_end < $1 OR c.contract_status=$2 LIMIT $3;` - rows, err := tx.Query(query, height, contracts.ContractStatusRejected, batchSize) - if err != nil { - return nil, fmt.Errorf("failed to query expired sectors: %w", err) - } - defer rows.Close() - for rows.Next() { - ref, err := scanContractSectorRef(rows) - if err != nil { - return nil, fmt.Errorf("failed to scan expired contract: %w", err) - } - sectors = append(sectors, ref) - } - return -} - func incrementPotentialRevenueMetrics(tx txn, usage contracts.Usage, negative bool) error { if err := incrementCurrencyStat(tx, metricPotentialRPCRevenue, usage.RPCRevenue, negative, time.Now()); err != nil { return fmt.Errorf("failed to increment rpc revenue stat: %w", err) diff --git a/persist/sqlite/init.sql b/persist/sqlite/init.sql index 38531948..5241c7a0 100644 --- a/persist/sqlite/init.sql +++ b/persist/sqlite/init.sql @@ -199,6 +199,7 @@ CREATE TABLE host_settings ( CREATE TABLE host_pinned_settings ( id INTEGER PRIMARY KEY NOT NULL DEFAULT 0 CHECK (id = 0), -- enforce a single row currency TEXT NOT NULL, + threshold REAL NOT NULL, storage_price REAL NOT NULL, ingress_price REAL NOT NULL, egress_price REAL NOT NULL, diff --git a/persist/sqlite/settings.go b/persist/sqlite/settings.go index b683f5b3..75b3ef52 100644 --- a/persist/sqlite/settings.go +++ b/persist/sqlite/settings.go @@ -16,10 +16,10 @@ import ( // PinnedSettings returns the host's pinned settings. func (s *Store) PinnedSettings() (pinned settings.PinnedSettings, err error) { - const query = `SELECT currency, storage_price, ingress_price, egress_price, max_collateral + const query = `SELECT currency, threshold, storage_price, ingress_price, egress_price, max_collateral FROM host_pinned_settings;` - err = s.queryRow(query).Scan(&pinned.Currency, &pinned.Storage, &pinned.Ingress, &pinned.Egress, &pinned.MaxCollateral) + err = s.queryRow(query).Scan(&pinned.Currency, &pinned.Threshold, &pinned.Storage, &pinned.Ingress, &pinned.Egress, &pinned.MaxCollateral) if errors.Is(err, sql.ErrNoRows) { return settings.PinnedSettings{}, nil } @@ -28,12 +28,12 @@ FROM host_pinned_settings;` // UpdatePinnedSettings updates the host's pinned settings. func (s *Store) UpdatePinnedSettings(p settings.PinnedSettings) error { - const query = `INSERT INTO host_pinned_settings (id, currency, storage_price, ingress_price, egress_price, max_collateral) VALUES (0, $1, $2, $3, $4, $5) -ON CONFLICT (id) DO UPDATE SET currency=EXCLUDED.currency, storage_price=EXCLUDED.storage_price, ingress_price=EXCLUDED.ingress_price, egress_price=EXCLUDED.egress_price, max_collateral=EXCLUDED.max_collateral + const query = `INSERT INTO host_pinned_settings (id, currency, threshold, storage_price, ingress_price, egress_price, max_collateral) VALUES (0, $1, $2, $3, $4, $5, $6) +ON CONFLICT (id) DO UPDATE SET currency=EXCLUDED.currency, threshold=EXCLUDED.threshold, storage_price=EXCLUDED.storage_price, ingress_price=EXCLUDED.ingress_price, egress_price=EXCLUDED.egress_price, max_collateral=EXCLUDED.max_collateral RETURNING id;` var dummyID int64 - err := s.queryRow(query, p.Currency, p.Storage, p.Ingress, p.Egress, p.MaxCollateral).Scan(&dummyID) + err := s.queryRow(query, p.Currency, p.Threshold, p.Storage, p.Ingress, p.Egress, p.MaxCollateral).Scan(&dummyID) return err } From 99dd03a868acacc41ed32d2d4c6f9a35c42717f1 Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Thu, 29 Feb 2024 16:45:07 -0800 Subject: [PATCH 06/20] api,cmd,settings,explorer,sqlite: move pin to separate package --- api/api.go | 5 +- api/endpoints.go | 3 +- api/options.go | 14 +++ cmd/hostd/node.go | 7 +- host/settings/options.go | 10 ++ host/settings/pin.go | 174 ------------------------------ host/settings/pin/pin.go | 195 ++++++++++++++++++++++++++++++++++ host/settings/settings.go | 40 +------ internal/explorer/explorer.go | 1 + persist/sqlite/settings.go | 7 +- 10 files changed, 237 insertions(+), 219 deletions(-) delete mode 100644 host/settings/pin.go create mode 100644 host/settings/pin/pin.go diff --git a/api/api.go b/api/api.go index eb0fd673..3a6832b1 100644 --- a/api/api.go +++ b/api/api.go @@ -14,6 +14,7 @@ import ( "go.sia.tech/hostd/host/contracts" "go.sia.tech/hostd/host/metrics" "go.sia.tech/hostd/host/settings" + "go.sia.tech/hostd/host/settings/pin" "go.sia.tech/hostd/host/storage" "go.sia.tech/hostd/rhp" "go.sia.tech/hostd/wallet" @@ -48,8 +49,8 @@ type ( // PinnedSettings updates and retrieves the host's pinned settings PinnedSettings interface { - Update(p settings.PinnedSettings) error - Pinned() settings.PinnedSettings + Update(p pin.PinnedSettings) error + Pinned() pin.PinnedSettings } // A MetricManager retrieves metrics related to the host diff --git a/api/endpoints.go b/api/endpoints.go index 022ea5b0..59e5e15c 100644 --- a/api/endpoints.go +++ b/api/endpoints.go @@ -16,6 +16,7 @@ import ( "go.sia.tech/hostd/host/contracts" "go.sia.tech/hostd/host/metrics" "go.sia.tech/hostd/host/settings" + "go.sia.tech/hostd/host/settings/pin" "go.sia.tech/hostd/host/storage" "go.sia.tech/hostd/internal/disk" "go.sia.tech/hostd/internal/prometheus" @@ -213,7 +214,7 @@ func (a *api) handlePUTPinnedSettings(c jape.Context) { return } - var req settings.PinnedSettings + var req pin.PinnedSettings if err := c.Decode(&req); err != nil { return } diff --git a/api/options.go b/api/options.go index 5732d1a7..38247f31 100644 --- a/api/options.go +++ b/api/options.go @@ -5,84 +5,98 @@ import "go.uber.org/zap" // ServerOption is a functional option to configure an API server. type ServerOption func(*api) +// ServerWithAlerts sets the alerts manager for the API server. func ServerWithAlerts(al Alerts) ServerOption { return func(a *api) { a.alerts = al } } +// ServerWithWebHooks sets the webhooks manager for the API server. func ServerWithWebHooks(w WebHooks) ServerOption { return func(a *api) { a.webhooks = w } } +// ServerWithSyncer sets the syncer for the API server. func ServerWithSyncer(g Syncer) ServerOption { return func(a *api) { a.syncer = g } } +// ServerWithChainManager sets the chain manager for the API server. func ServerWithChainManager(chain ChainManager) ServerOption { return func(a *api) { a.chain = chain } } +// ServerWithTransactionPool sets the transaction pool for the API server. func ServerWithTransactionPool(tp TPool) ServerOption { return func(a *api) { a.tpool = tp } } +// ServerWithContractManager sets the contract manager for the API server. func ServerWithContractManager(cm ContractManager) ServerOption { return func(a *api) { a.contracts = cm } } +// ServerWithAccountManager sets the account manager for the API server. func ServerWithAccountManager(am AccountManager) ServerOption { return func(a *api) { a.accounts = am } } +// ServerWithVolumeManager sets the volume manager for the API server. func ServerWithVolumeManager(vm VolumeManager) ServerOption { return func(a *api) { a.volumes = vm } } +// ServerWithMetricManager sets the metric manager for the API server. func ServerWithMetricManager(m MetricManager) ServerOption { return func(a *api) { a.metrics = m } } +// ServerWithPinnedSettings sets the pinned settings for the API server. func ServerWithPinnedSettings(p PinnedSettings) ServerOption { return func(a *api) { a.pinned = p } } +// ServerWithSettings sets the settings manager for the API server. func ServerWithSettings(s Settings) ServerOption { return func(a *api) { a.settings = s } } +// ServerWithRHPSessionReporter sets the RHP session reporter for the API server. func ServerWithRHPSessionReporter(rsr RHPSessionReporter) ServerOption { return func(a *api) { a.sessions = rsr } } +// ServerWithWallet sets the wallet for the API server. func ServerWithWallet(w Wallet) ServerOption { return func(a *api) { a.wallet = w } } +// ServerWithLogger sets the logger for the API server. func ServerWithLogger(log *zap.Logger) ServerOption { return func(a *api) { a.log = log diff --git a/cmd/hostd/node.go b/cmd/hostd/node.go index 554f1f25..edb160c7 100644 --- a/cmd/hostd/node.go +++ b/cmd/hostd/node.go @@ -16,6 +16,7 @@ import ( "go.sia.tech/hostd/host/metrics" "go.sia.tech/hostd/host/registry" "go.sia.tech/hostd/host/settings" + "go.sia.tech/hostd/host/settings/pin" "go.sia.tech/hostd/host/storage" "go.sia.tech/hostd/internal/chain" "go.sia.tech/hostd/internal/explorer" @@ -43,7 +44,7 @@ type node struct { metrics *metrics.MetricManager settings *settings.ConfigManager - pinned *settings.PinManager + pinned *pin.Manager accounts *accounts.AccountManager contracts *contracts.ContractManager registry *registry.Manager @@ -185,11 +186,11 @@ func newNode(ctx context.Context, walletKey types.PrivateKey, logger *zap.Logger return nil, types.PrivateKey{}, fmt.Errorf("failed to create settings manager: %w", err) } - var pm *settings.PinManager + var pm *pin.Manager if !cfg.Explorer.Disable { ex := explorer.New(cfg.Explorer.URL) - pm, err = settings.NewPinManager(5*time.Minute, db, ex, sr, logger.Named("pin")) + pm, err = pin.NewManager(5*time.Minute, db, ex, sr, logger.Named("pin")) if err != nil { return nil, types.PrivateKey{}, fmt.Errorf("failed to create pin manager: %w", err) } diff --git a/host/settings/options.go b/host/settings/options.go index 776b0fc2..4f48e033 100644 --- a/host/settings/options.go +++ b/host/settings/options.go @@ -5,6 +5,8 @@ import ( "go.uber.org/zap" ) +// An Option is a functional option that can be used to configure a config +// manager. type Option func(*ConfigManager) // WithLog sets the logger for the settings manager. @@ -14,48 +16,56 @@ func WithLog(log *zap.Logger) Option { } } +// WithStore sets the store for the settings manager. func WithStore(s Store) Option { return func(cm *ConfigManager) { cm.store = s } } +// WithChainManager sets the chain manager for the settings manager. func WithChainManager(cm ChainManager) Option { return func(c *ConfigManager) { c.cm = cm } } +// WithTransactionPool sets the transaction pool for the settings manager. func WithTransactionPool(tp TransactionPool) Option { return func(c *ConfigManager) { c.tp = tp } } +// WithWallet sets the wallet for the settings manager. func WithWallet(w Wallet) Option { return func(c *ConfigManager) { c.wallet = w } } +// WithAlertManager sets the alerts manager for the settings manager. func WithAlertManager(am Alerts) Option { return func(c *ConfigManager) { c.a = am } } +// WithHostKey sets the host key for the settings manager. func WithHostKey(pk types.PrivateKey) Option { return func(c *ConfigManager) { c.hostKey = pk } } +// WithRHP2Addr sets the address of the RHP2 server. func WithRHP2Addr(addr string) Option { return func(c *ConfigManager) { c.discoveredRHPAddr = addr } } +// WithCertificateFiles sets the certificate files for the settings manager. func WithCertificateFiles(certFilePath, keyFilePath string) Option { return func(c *ConfigManager) { c.certCertFilePath = certFilePath diff --git a/host/settings/pin.go b/host/settings/pin.go deleted file mode 100644 index 68899432..00000000 --- a/host/settings/pin.go +++ /dev/null @@ -1,174 +0,0 @@ -package settings - -import ( - "context" - "fmt" - "sync" - "time" - - "github.com/shopspring/decimal" - "go.sia.tech/core/types" - "go.sia.tech/hostd/internal/explorer" - "go.uber.org/zap" -) - -type ( - // A PinStore stores and retrieves pinned settings. - PinStore interface { - PinnedSettings() (PinnedSettings, error) - UpdatePinnedSettings(PinnedSettings) error - } - - // A PinManager manages the host's pinned settings and updates the host's - // settings based on the current exchange rate. - PinManager struct { - log *zap.Logger - store PinStore - explorer *explorer.Explorer - cm *ConfigManager - - frequency time.Duration - - mu sync.Mutex - rates []decimal.Decimal - lastRate decimal.Decimal - pinned PinnedSettings // in-memory cache of pinned settings - } -) - -func isOverThreshold(a, b, percentage decimal.Decimal) bool { - threshold := a.Mul(percentage) - diff := a.Sub(b).Abs() - return diff.GreaterThan(threshold) -} - -func convertToCurrency(target decimal.Decimal, rate decimal.Decimal) types.Currency { - hastings := target.Div(rate).Mul(decimal.New(1, 24)).Round(0).String() - c, err := types.ParseCurrency(hastings) - if err != nil { - panic(err) - } - return c -} - -func (pm *PinManager) update(ctx context.Context) error { - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - pm.mu.Lock() - currency := pm.pinned.Currency - pm.mu.Unlock() - - current, err := pm.explorer.SiacoinExchangeRate(ctx, currency) - if err != nil { - return fmt.Errorf("failed to get exchange rate: %w", err) - } else if current.IsZero() { - return fmt.Errorf("exchange rate is zero") - } - - pm.mu.Lock() - defer pm.mu.Unlock() - - maxRates := int(12 * time.Hour / pm.frequency) - pm.rates = append(pm.rates, current) - if len(pm.rates) >= maxRates { - pm.rates = pm.rates[1:] - } - - // skip updating prices if the pinned settings are zero - if pm.pinned.Storage.IsZero() && pm.pinned.Ingress.IsZero() && pm.pinned.Egress.IsZero() && pm.pinned.MaxCollateral.IsZero() { - return nil - } - - var sum decimal.Decimal - for _, r := range pm.rates { - sum = sum.Add(r) - } - avgRate := sum.Div(decimal.New(int64(len(pm.rates)), 0)) - - if !isOverThreshold(pm.lastRate, avgRate, pm.pinned.Threshold) { - pm.log.Debug("new rate not over threshold", zap.Stringer("current", current), zap.Stringer("average", avgRate), zap.Stringer("last", pm.lastRate)) - return nil - } - pm.lastRate = avgRate - - settings := pm.cm.Settings() - if !pm.pinned.Storage.IsZero() { - settings.StoragePrice = convertToCurrency(pm.pinned.Storage, avgRate).Div64(4320).Div64(1e12) - } - - if !pm.pinned.Ingress.IsZero() { - settings.IngressPrice = convertToCurrency(pm.pinned.Ingress, avgRate).Div64(1e12) - } - - if !pm.pinned.Egress.IsZero() { - settings.EgressPrice = convertToCurrency(pm.pinned.Egress, avgRate).Div64(1e12) - } - - if !pm.pinned.MaxCollateral.IsZero() { - settings.MaxCollateral = convertToCurrency(pm.pinned.MaxCollateral, avgRate).Div64(4320).Div64(1e12) - } - - if err := pm.cm.UpdateSettings(settings); err != nil { - return fmt.Errorf("failed to update settings: %w", err) - } - pm.log.Debug("updated prices", zap.Stringer("storage", settings.StoragePrice), zap.Stringer("ingress", settings.IngressPrice), zap.Stringer("egress", settings.EgressPrice)) - return nil -} - -// Pinned returns the host's pinned settings. -func (pm *PinManager) Pinned() PinnedSettings { - pm.mu.Lock() - defer pm.mu.Unlock() - return pm.pinned -} - -// Update updates the host's pinned settings. -func (pm *PinManager) Update(p PinnedSettings) error { - pm.mu.Lock() - if pm.pinned.Currency != p.Currency { - pm.rates = pm.rates[:0] // currency has changed, reset rates - } - pm.pinned = p - pm.mu.Unlock() - if err := pm.store.UpdatePinnedSettings(p); err != nil { - return fmt.Errorf("failed to update pinned settings: %w", err) - } else if err := pm.update(context.Background()); err != nil { - return fmt.Errorf("failed to update prices: %w", err) - } - return nil -} - -// Run starts the PinManager's update loop. -func (pm *PinManager) Run(ctx context.Context) error { - t := time.NewTicker(5 * time.Minute) - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-t.C: - if err := pm.update(ctx); err != nil { - pm.log.Error("failed to update prices", zap.Error(err)) - } - } - } -} - -// NewPinManager creates a new PinManager. -func NewPinManager(frequency time.Duration, store PinStore, explorer *explorer.Explorer, cm *ConfigManager, log *zap.Logger) (*PinManager, error) { - pinned, err := store.PinnedSettings() - if err != nil { - return nil, fmt.Errorf("failed to get pinned settings: %w", err) - } - - return &PinManager{ - log: log, - store: store, - explorer: explorer, - cm: cm, - - frequency: frequency, - pinned: pinned, - }, nil -} diff --git a/host/settings/pin/pin.go b/host/settings/pin/pin.go new file mode 100644 index 00000000..084d0114 --- /dev/null +++ b/host/settings/pin/pin.go @@ -0,0 +1,195 @@ +package pin + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/shopspring/decimal" + "go.sia.tech/core/types" + "go.sia.tech/hostd/host/settings" + "go.sia.tech/hostd/internal/explorer" + "go.uber.org/zap" +) + +type ( + // PinnedSettings contains the settings that can be optionally + // pinned to an external currency. This uses an external explorer + // to retrieve the current exchange rate. + PinnedSettings struct { + Currency string `json:"currency"` + Threshold decimal.Decimal `json:"threshold"` + + Storage decimal.Decimal `json:"storage"` + Ingress decimal.Decimal `json:"ingress"` + Egress decimal.Decimal `json:"egress"` + + MaxCollateral decimal.Decimal `json:"maxCollateral"` + } + + // A SettingsManager updates and retrieves the host's settings. + SettingsManager interface { + Settings() settings.Settings + UpdateSettings(settings.Settings) error + } + + // A Store stores and retrieves pinned settings. + Store interface { + PinnedSettings() (PinnedSettings, error) + UpdatePinnedSettings(PinnedSettings) error + } + + // A Manager manages the host's pinned settings and updates the host's + // settings based on the current exchange rate. + Manager struct { + log *zap.Logger + store Store + explorer *explorer.Explorer + sm SettingsManager + + frequency time.Duration + + mu sync.Mutex + rates []decimal.Decimal + lastRate decimal.Decimal + pinned PinnedSettings // in-memory cache of pinned settings + } +) + +func isOverThreshold(a, b, percentage decimal.Decimal) bool { + threshold := a.Mul(percentage) + diff := a.Sub(b).Abs() + return diff.GreaterThan(threshold) +} + +func convertToCurrency(target decimal.Decimal, rate decimal.Decimal) types.Currency { + hastings := target.Div(rate).Mul(decimal.New(1, 24)).Round(0).String() + c, err := types.ParseCurrency(hastings) + if err != nil { + panic(err) + } + return c +} + +func (m *Manager) updatePrices(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + m.mu.Lock() + currency := m.pinned.Currency + m.mu.Unlock() + + current, err := m.explorer.SiacoinExchangeRate(ctx, currency) + if err != nil { + return fmt.Errorf("failed to get exchange rate: %w", err) + } else if current.IsZero() { + return fmt.Errorf("exchange rate is zero") + } + + m.mu.Lock() + defer m.mu.Unlock() + + maxRates := int(12 * time.Hour / m.frequency) + m.rates = append(m.rates, current) + if len(m.rates) >= maxRates { + m.rates = m.rates[1:] + } + + // skip updating prices if the pinned settings are zero + if m.pinned.Storage.IsZero() && m.pinned.Ingress.IsZero() && m.pinned.Egress.IsZero() && m.pinned.MaxCollateral.IsZero() { + return nil + } + + var sum decimal.Decimal + for _, r := range m.rates { + sum = sum.Add(r) + } + avgRate := sum.Div(decimal.New(int64(len(m.rates)), 0)) + + if !isOverThreshold(m.lastRate, avgRate, m.pinned.Threshold) { + m.log.Debug("new rate not over threshold", zap.Stringer("current", current), zap.Stringer("average", avgRate), zap.Stringer("last", m.lastRate)) + return nil + } + m.lastRate = avgRate + + settings := m.sm.Settings() + if !m.pinned.Storage.IsZero() { + settings.StoragePrice = convertToCurrency(m.pinned.Storage, avgRate).Div64(4320).Div64(1e12) + } + + if !m.pinned.Ingress.IsZero() { + settings.IngressPrice = convertToCurrency(m.pinned.Ingress, avgRate).Div64(1e12) + } + + if !m.pinned.Egress.IsZero() { + settings.EgressPrice = convertToCurrency(m.pinned.Egress, avgRate).Div64(1e12) + } + + if !m.pinned.MaxCollateral.IsZero() { + settings.MaxCollateral = convertToCurrency(m.pinned.MaxCollateral, avgRate).Div64(4320).Div64(1e12) + } + + if err := m.sm.UpdateSettings(settings); err != nil { + return fmt.Errorf("failed to update settings: %w", err) + } + m.log.Debug("updated prices", zap.Stringer("storage", settings.StoragePrice), zap.Stringer("ingress", settings.IngressPrice), zap.Stringer("egress", settings.EgressPrice)) + return nil +} + +// Pinned returns the host's pinned settings. +func (m *Manager) Pinned() PinnedSettings { + m.mu.Lock() + defer m.mu.Unlock() + return m.pinned +} + +// Update updates the host's pinned settings. +func (m *Manager) Update(p PinnedSettings) error { + m.mu.Lock() + if m.pinned.Currency != p.Currency { + m.rates = m.rates[:0] // currency has changed, reset rates + } + m.pinned = p + m.mu.Unlock() + if err := m.store.UpdatePinnedSettings(p); err != nil { + return fmt.Errorf("failed to update pinned settings: %w", err) + } else if err := m.updatePrices(context.Background()); err != nil { + return fmt.Errorf("failed to update prices: %w", err) + } + return nil +} + +// Run starts the PinManager's update loop. +func (m *Manager) Run(ctx context.Context) error { + t := time.NewTicker(5 * time.Minute) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + if err := m.updatePrices(ctx); err != nil { + m.log.Error("failed to update prices", zap.Error(err)) + } + } + } +} + +// NewManager creates a new pin manager. +func NewManager(frequency time.Duration, store Store, explorer *explorer.Explorer, sm SettingsManager, log *zap.Logger) (*Manager, error) { + pinned, err := store.PinnedSettings() + if err != nil { + return nil, fmt.Errorf("failed to get pinned settings: %w", err) + } + + return &Manager{ + log: log, + store: store, + explorer: explorer, + sm: sm, + + frequency: frequency, + pinned: pinned, + }, nil +} diff --git a/host/settings/settings.go b/host/settings/settings.go index 60ce0442..5579338a 100644 --- a/host/settings/settings.go +++ b/host/settings/settings.go @@ -11,7 +11,6 @@ import ( "sync" "time" - "github.com/shopspring/decimal" "go.sia.tech/core/consensus" "go.sia.tech/core/types" "go.sia.tech/hostd/alerts" @@ -49,20 +48,6 @@ type ( LastSettingsConsensusChange() (modules.ConsensusChangeID, uint64, error) } - // PinnedSettings contains the settings that can be optionally - // pinned to an external currency. This uses an external explorer - // to retrieve the current exchange rate. - PinnedSettings struct { - Currency string `json:"currency"` - Threshold decimal.Decimal `json:"threshold"` - - Storage decimal.Decimal `json:"storage"` - Ingress decimal.Decimal `json:"ingress"` - Egress decimal.Decimal `json:"egress"` - - MaxCollateral decimal.Decimal `json:"maxCollateral"` - } - // Settings contains configuration options for the host. Settings struct { // Host settings @@ -143,11 +128,10 @@ type ( tp TransactionPool wallet Wallet - mu sync.Mutex // guards the following fields - settings Settings // in-memory cache of the host's settings - pinned PinnedSettings // in-memory cache of the host's pinned settings - scanHeight uint64 // track the last block height that was scanned for announcements - lastAnnounceAttempt uint64 // debounce announcement transactions + mu sync.Mutex // guards the following fields + settings Settings // in-memory cache of the host's settings + scanHeight uint64 // track the last block height that was scanned for announcements + lastAnnounceAttempt uint64 // debounce announcement transactions ingressLimit *rate.Limiter egressLimit *rate.Limiter @@ -261,22 +245,6 @@ func (m *ConfigManager) Settings() Settings { return m.settings } -// Pinned returns the pinned settings -func (m *ConfigManager) Pinned() PinnedSettings { - m.mu.Lock() - defer m.mu.Unlock() - return m.pinned -} - -// UpdatePinned updates the pinned settings. If the exchange rate manager is not -// initialized, an error is returned. -func (m *ConfigManager) UpdatePinned(p PinnedSettings) error { - m.mu.Lock() - m.pinned = p - m.mu.Unlock() - return nil -} - // BandwidthLimiters returns the rate limiters for all traffic func (m *ConfigManager) BandwidthLimiters() (ingress, egress *rate.Limiter) { return m.ingressLimit, m.egressLimit diff --git a/internal/explorer/explorer.go b/internal/explorer/explorer.go index e1553971..497e21d0 100644 --- a/internal/explorer/explorer.go +++ b/internal/explorer/explorer.go @@ -13,6 +13,7 @@ import ( "github.com/shopspring/decimal" ) +// An Explorer retrieves data about the Sia network from an external source. type Explorer struct { url string } diff --git a/persist/sqlite/settings.go b/persist/sqlite/settings.go index 75b3ef52..bb8d9505 100644 --- a/persist/sqlite/settings.go +++ b/persist/sqlite/settings.go @@ -10,24 +10,25 @@ import ( "go.sia.tech/core/types" "go.sia.tech/hostd/host/settings" + "go.sia.tech/hostd/host/settings/pin" "go.sia.tech/siad/modules" "go.uber.org/zap" ) // PinnedSettings returns the host's pinned settings. -func (s *Store) PinnedSettings() (pinned settings.PinnedSettings, err error) { +func (s *Store) PinnedSettings() (pinned pin.PinnedSettings, err error) { const query = `SELECT currency, threshold, storage_price, ingress_price, egress_price, max_collateral FROM host_pinned_settings;` err = s.queryRow(query).Scan(&pinned.Currency, &pinned.Threshold, &pinned.Storage, &pinned.Ingress, &pinned.Egress, &pinned.MaxCollateral) if errors.Is(err, sql.ErrNoRows) { - return settings.PinnedSettings{}, nil + return pin.PinnedSettings{}, nil } return } // UpdatePinnedSettings updates the host's pinned settings. -func (s *Store) UpdatePinnedSettings(p settings.PinnedSettings) error { +func (s *Store) UpdatePinnedSettings(p pin.PinnedSettings) error { const query = `INSERT INTO host_pinned_settings (id, currency, threshold, storage_price, ingress_price, egress_price, max_collateral) VALUES (0, $1, $2, $3, $4, $5, $6) ON CONFLICT (id) DO UPDATE SET currency=EXCLUDED.currency, threshold=EXCLUDED.threshold, storage_price=EXCLUDED.storage_price, ingress_price=EXCLUDED.ingress_price, egress_price=EXCLUDED.egress_price, max_collateral=EXCLUDED.max_collateral RETURNING id;` From 846010f32b92095f990c948eb8be146527712c7c Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Sun, 10 Mar 2024 12:37:53 -0700 Subject: [PATCH 07/20] pin: change price update log to info --- host/settings/pin/pin.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/host/settings/pin/pin.go b/host/settings/pin/pin.go index 084d0114..b77d4e9d 100644 --- a/host/settings/pin/pin.go +++ b/host/settings/pin/pin.go @@ -133,7 +133,7 @@ func (m *Manager) updatePrices(ctx context.Context) error { if err := m.sm.UpdateSettings(settings); err != nil { return fmt.Errorf("failed to update settings: %w", err) } - m.log.Debug("updated prices", zap.Stringer("storage", settings.StoragePrice), zap.Stringer("ingress", settings.IngressPrice), zap.Stringer("egress", settings.EgressPrice)) + m.log.Info("updated prices", zap.Stringer("storage", settings.StoragePrice), zap.Stringer("ingress", settings.IngressPrice), zap.Stringer("egress", settings.EgressPrice)) return nil } From 48fb546416ee59561eb35259d907194c60af917b Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Sun, 10 Mar 2024 15:17:11 -0700 Subject: [PATCH 08/20] pin: add tests, use functional options --- host/settings/pin/options.go | 53 +++++++ host/settings/pin/pin.go | 167 ++++++++++++++------- host/settings/pin/pin_test.go | 270 ++++++++++++++++++++++++++++++++++ 3 files changed, 440 insertions(+), 50 deletions(-) create mode 100644 host/settings/pin/options.go create mode 100644 host/settings/pin/pin_test.go diff --git a/host/settings/pin/options.go b/host/settings/pin/options.go new file mode 100644 index 00000000..ea758223 --- /dev/null +++ b/host/settings/pin/options.go @@ -0,0 +1,53 @@ +package pin + +import ( + "time" + + "go.uber.org/zap" +) + +type Option func(*Manager) + +// WithLogger sets the logger for the manager. +func WithLogger(log *zap.Logger) Option { + return func(m *Manager) { + m.log = log + } +} + +// WithFrequency sets the frequency at which the manager updates the host's +// settings based on the current exchange rate. +func WithFrequency(frequency time.Duration) Option { + return func(m *Manager) { + m.frequency = frequency + } +} + +// WithSettings sets the settings manager for the manager. +func WithSettings(s SettingsManager) Option { + return func(m *Manager) { + m.sm = s + } +} + +// WithStore sets the store for the manager. +func WithStore(s Store) Option { + return func(m *Manager) { + m.store = s + } +} + +// WithExchangeRateRetriever sets the exchange rate retriever for the manager. +func WithExchangeRateRetriever(e ExchangeRateRetriever) Option { + return func(m *Manager) { + m.explorer = e + } +} + +// WithAverageRateWindow sets the window over which the manager calculates the +// average exchange rate. +func WithAverageRateWindow(window time.Duration) Option { + return func(m *Manager) { + m.rateWindow = window + } +} diff --git a/host/settings/pin/pin.go b/host/settings/pin/pin.go index b77d4e9d..2d6e4d0f 100644 --- a/host/settings/pin/pin.go +++ b/host/settings/pin/pin.go @@ -9,7 +9,6 @@ import ( "github.com/shopspring/decimal" "go.sia.tech/core/types" "go.sia.tech/hostd/host/settings" - "go.sia.tech/hostd/internal/explorer" "go.uber.org/zap" ) @@ -18,14 +17,24 @@ type ( // pinned to an external currency. This uses an external explorer // to retrieve the current exchange rate. PinnedSettings struct { - Currency string `json:"currency"` - Threshold decimal.Decimal `json:"threshold"` - - Storage decimal.Decimal `json:"storage"` - Ingress decimal.Decimal `json:"ingress"` - Egress decimal.Decimal `json:"egress"` - - MaxCollateral decimal.Decimal `json:"maxCollateral"` + // Currency is the external three letter currency code. If empty, + // pinning is disabled. If the explorer does not support the + // currency an error is returned. + Currency string `json:"currency"` + + // Threshold is a percentage from 0 to 1 that determines when the + // host's settings are updated based on the current exchange rate. + Threshold float64 `json:"threshold"` + + // Storage, Ingress, and Egress are the pinned prices in the + // external currency. + Storage float64 `json:"storage"` + Ingress float64 `json:"ingress"` + Egress float64 `json:"egress"` + + // MaxCollateral is the maximum collateral that the host will + // accept in the external currency. + MaxCollateral float64 `json:"maxCollateral"` } // A SettingsManager updates and retrieves the host's settings. @@ -40,15 +49,22 @@ type ( UpdatePinnedSettings(PinnedSettings) error } + // An ExchangeRateRetriever retrieves the current exchange rate from + // an external source. + ExchangeRateRetriever interface { + SiacoinExchangeRate(ctx context.Context, currency string) (float64, error) + } + // A Manager manages the host's pinned settings and updates the host's // settings based on the current exchange rate. Manager struct { log *zap.Logger store Store - explorer *explorer.Explorer + explorer ExchangeRateRetriever sm SettingsManager - frequency time.Duration + frequency time.Duration + rateWindow time.Duration mu sync.Mutex rates []decimal.Decimal @@ -60,7 +76,7 @@ type ( func isOverThreshold(a, b, percentage decimal.Decimal) bool { threshold := a.Mul(percentage) diff := a.Sub(b).Abs() - return diff.GreaterThan(threshold) + return diff.GreaterThanOrEqual(threshold) } func convertToCurrency(target decimal.Decimal, rate decimal.Decimal) types.Currency { @@ -72,7 +88,15 @@ func convertToCurrency(target decimal.Decimal, rate decimal.Decimal) types.Curre return c } -func (m *Manager) updatePrices(ctx context.Context) error { +func averageRate(rates []decimal.Decimal) decimal.Decimal { + var sum decimal.Decimal + for _, r := range rates { + sum = sum.Add(r) + } + return sum.Div(decimal.NewFromInt(int64(len(rates)))) +} + +func (m *Manager) updatePrices(ctx context.Context, force bool) error { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() @@ -80,60 +104,63 @@ func (m *Manager) updatePrices(ctx context.Context) error { currency := m.pinned.Currency m.mu.Unlock() - current, err := m.explorer.SiacoinExchangeRate(ctx, currency) + if currency == "" { + return nil + } + + rate, err := m.explorer.SiacoinExchangeRate(ctx, currency) if err != nil { return fmt.Errorf("failed to get exchange rate: %w", err) - } else if current.IsZero() { - return fmt.Errorf("exchange rate is zero") + } else if rate <= 0 { + return fmt.Errorf("exchange rate must be positive") } + current := decimal.NewFromFloat(rate) m.mu.Lock() defer m.mu.Unlock() - maxRates := int(12 * time.Hour / m.frequency) + maxRates := int(m.rateWindow / m.frequency) m.rates = append(m.rates, current) if len(m.rates) >= maxRates { m.rates = m.rates[1:] } // skip updating prices if the pinned settings are zero - if m.pinned.Storage.IsZero() && m.pinned.Ingress.IsZero() && m.pinned.Egress.IsZero() && m.pinned.MaxCollateral.IsZero() { + if m.pinned.Storage <= 0 && m.pinned.Ingress <= 0 && m.pinned.Egress <= 0 && m.pinned.MaxCollateral <= 0 { return nil } - var sum decimal.Decimal - for _, r := range m.rates { - sum = sum.Add(r) - } - avgRate := sum.Div(decimal.New(int64(len(m.rates)), 0)) + avgRate := averageRate(m.rates) + threshold := decimal.NewFromFloat(m.pinned.Threshold) - if !isOverThreshold(m.lastRate, avgRate, m.pinned.Threshold) { - m.log.Debug("new rate not over threshold", zap.Stringer("current", current), zap.Stringer("average", avgRate), zap.Stringer("last", m.lastRate)) + log := m.log.With(zap.String("currency", currency), zap.Stringer("threshold", threshold), zap.Stringer("current", current), zap.Stringer("average", avgRate), zap.Stringer("last", m.lastRate)) + if !force && !isOverThreshold(m.lastRate, avgRate, threshold) { + log.Debug("new rate not over threshold") return nil } m.lastRate = avgRate settings := m.sm.Settings() - if !m.pinned.Storage.IsZero() { - settings.StoragePrice = convertToCurrency(m.pinned.Storage, avgRate).Div64(4320).Div64(1e12) + if m.pinned.Storage > 0 { + settings.StoragePrice = convertToCurrency(decimal.NewFromFloat(m.pinned.Storage), avgRate).Div64(4320).Div64(1e12) } - if !m.pinned.Ingress.IsZero() { - settings.IngressPrice = convertToCurrency(m.pinned.Ingress, avgRate).Div64(1e12) + if m.pinned.Ingress > 0 { + settings.IngressPrice = convertToCurrency(decimal.NewFromFloat(m.pinned.Ingress), avgRate).Div64(1e12) } - if !m.pinned.Egress.IsZero() { - settings.EgressPrice = convertToCurrency(m.pinned.Egress, avgRate).Div64(1e12) + if m.pinned.Egress > 0 { + settings.EgressPrice = convertToCurrency(decimal.NewFromFloat(m.pinned.Egress), avgRate).Div64(1e12) } - if !m.pinned.MaxCollateral.IsZero() { - settings.MaxCollateral = convertToCurrency(m.pinned.MaxCollateral, avgRate).Div64(4320).Div64(1e12) + if m.pinned.MaxCollateral > 0 { + settings.MaxCollateral = convertToCurrency(decimal.NewFromFloat(m.pinned.MaxCollateral), avgRate) } if err := m.sm.UpdateSettings(settings); err != nil { return fmt.Errorf("failed to update settings: %w", err) } - m.log.Info("updated prices", zap.Stringer("storage", settings.StoragePrice), zap.Stringer("ingress", settings.IngressPrice), zap.Stringer("egress", settings.EgressPrice)) + log.Info("updated prices", zap.Stringer("storage", settings.StoragePrice), zap.Stringer("ingress", settings.IngressPrice), zap.Stringer("egress", settings.EgressPrice)) return nil } @@ -145,7 +172,22 @@ func (m *Manager) Pinned() PinnedSettings { } // Update updates the host's pinned settings. -func (m *Manager) Update(p PinnedSettings) error { +func (m *Manager) Update(ctx context.Context, p PinnedSettings) error { + switch { + case p.Currency == "": + return fmt.Errorf("currency must be set") + case p.Threshold < 0 || p.Threshold > 1: + return fmt.Errorf("threshold must be between 0 and 1") + case p.Storage < 0: + return fmt.Errorf("storage price must be non-negative") + case p.Ingress < 0: + return fmt.Errorf("ingress price must be non-negative") + case p.Egress < 0: + return fmt.Errorf("egress price must be non-negative") + case p.MaxCollateral < 0: + return fmt.Errorf("max collateral must be non-negative") + } + m.mu.Lock() if m.pinned.Currency != p.Currency { m.rates = m.rates[:0] // currency has changed, reset rates @@ -154,7 +196,7 @@ func (m *Manager) Update(p PinnedSettings) error { m.mu.Unlock() if err := m.store.UpdatePinnedSettings(p); err != nil { return fmt.Errorf("failed to update pinned settings: %w", err) - } else if err := m.updatePrices(context.Background()); err != nil { + } else if err := m.updatePrices(ctx, true); err != nil { return fmt.Errorf("failed to update prices: %w", err) } return nil @@ -162,14 +204,19 @@ func (m *Manager) Update(p PinnedSettings) error { // Run starts the PinManager's update loop. func (m *Manager) Run(ctx context.Context) error { - t := time.NewTicker(5 * time.Minute) + t := time.NewTicker(m.frequency) + + // update prices immediately + if err := m.updatePrices(ctx, true); err != nil { + m.log.Error("failed to update prices", zap.Error(err)) + } for { select { case <-ctx.Done(): return ctx.Err() case <-t.C: - if err := m.updatePrices(ctx); err != nil { + if err := m.updatePrices(ctx, false); err != nil { m.log.Error("failed to update prices", zap.Error(err)) } } @@ -177,19 +224,39 @@ func (m *Manager) Run(ctx context.Context) error { } // NewManager creates a new pin manager. -func NewManager(frequency time.Duration, store Store, explorer *explorer.Explorer, sm SettingsManager, log *zap.Logger) (*Manager, error) { - pinned, err := store.PinnedSettings() - if err != nil { - return nil, fmt.Errorf("failed to get pinned settings: %w", err) +func NewManager(opts ...Option) (*Manager, error) { + m := &Manager{ + log: zap.NewNop(), + + frequency: 5 * time.Minute, + rateWindow: 6 * time.Hour, + } + + for _, opt := range opts { + opt(m) } - return &Manager{ - log: log, - store: store, - explorer: explorer, - sm: sm, + if m.store == nil { + return nil, fmt.Errorf("store is required") + } else if m.explorer == nil { + return nil, fmt.Errorf("exchange rate retriever is required") + } else if m.sm == nil { + return nil, fmt.Errorf("settings manager is required") + } else if m.log == nil { + return nil, fmt.Errorf("logger is required") + } else if m.frequency <= 0 { + return nil, fmt.Errorf("frequency must be positive") + } else if m.rateWindow <= 0 { + return nil, fmt.Errorf("rate window must be positive") + } else if m.rateWindow < m.frequency { + return nil, fmt.Errorf("rate window must be greater than or equal to frequency") + } - frequency: frequency, - pinned: pinned, - }, nil + // load the current pinned settings + pinned, err := m.store.PinnedSettings() + if err != nil { + return nil, fmt.Errorf("failed to get pinned settings: %w", err) + } + m.pinned = pinned + return m, nil } diff --git a/host/settings/pin/pin_test.go b/host/settings/pin/pin_test.go new file mode 100644 index 00000000..e2ad7a5f --- /dev/null +++ b/host/settings/pin/pin_test.go @@ -0,0 +1,270 @@ +package pin_test + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/shopspring/decimal" + "go.sia.tech/core/types" + "go.sia.tech/hostd/host/settings" + "go.sia.tech/hostd/host/settings/pin" + "go.sia.tech/hostd/internal/test" + "go.sia.tech/hostd/persist/sqlite" + "go.uber.org/zap/zaptest" +) + +type exchangeRateRetrieverStub struct { + mu sync.Mutex + value float64 + currency string +} + +func (e *exchangeRateRetrieverStub) updateRate(value float64) { + e.mu.Lock() + defer e.mu.Unlock() + e.value = value +} + +func (e *exchangeRateRetrieverStub) SiacoinExchangeRate(_ context.Context, currency string) (float64, error) { + e.mu.Lock() + defer e.mu.Unlock() + + if !strings.EqualFold(currency, e.currency) { + return 0, errors.New("currency not found") + } + return e.value, nil +} + +func convertToCurrency(target decimal.Decimal, rate decimal.Decimal) types.Currency { + hastings := target.Div(rate).Mul(decimal.New(1, 24)).Round(0).String() + c, err := types.ParseCurrency(hastings) + if err != nil { + panic(err) + } + return c +} + +func checkSettings(settings settings.Settings, pinned pin.PinnedSettings, expectedRate float64) error { + rate := decimal.NewFromFloat(expectedRate) + if pinned.Storage > 0 { + storagePrice := convertToCurrency(decimal.NewFromFloat(pinned.Storage), rate).Div64(4320).Div64(1e12) + if !storagePrice.Equals(settings.StoragePrice) { + return fmt.Errorf("expected storage price %d, got %d", storagePrice, settings.StoragePrice) + } + } + + if pinned.Ingress > 0 { + ingressPrice := convertToCurrency(decimal.NewFromFloat(pinned.Ingress), rate).Div64(1e12) + if !ingressPrice.Equals(settings.IngressPrice) { + return fmt.Errorf("expected ingress price %d, got %d", ingressPrice, settings.IngressPrice) + } + } + + if pinned.Egress > 0 { + egressPrice := convertToCurrency(decimal.NewFromFloat(pinned.Egress), rate).Div64(1e12) + if !egressPrice.Equals(settings.EgressPrice) { + return fmt.Errorf("expected egress price %d, got %d", egressPrice, settings.EgressPrice) + } + } + + if pinned.MaxCollateral > 0 { + maxCollateral := convertToCurrency(decimal.NewFromFloat(pinned.MaxCollateral), rate) + if !maxCollateral.Equals(settings.MaxCollateral) { + return fmt.Errorf("expected max collateral %d, got %d", maxCollateral, settings.MaxCollateral) + } + } + return nil +} + +func TestPinnedFields(t *testing.T) { + log := zaptest.NewLogger(t) + db, err := sqlite.OpenDatabase(filepath.Join(t.TempDir(), "test.db"), log) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + rr := &exchangeRateRetrieverStub{ + value: 1, + currency: "usd", + } + + node, err := test.NewNode(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer node.Close() + + sm, err := settings.NewConfigManager(settings.WithHostKey(types.GeneratePrivateKey()), settings.WithStore(db), settings.WithChainManager(node.ChainManager())) + if err != nil { + t.Fatal(err) + } + defer sm.Close() + + pm, err := pin.NewManager(pin.WithAverageRateWindow(time.Minute), + pin.WithFrequency(100*time.Millisecond), + pin.WithExchangeRateRetriever(rr), + pin.WithSettings(sm), + pin.WithStore(db), + pin.WithLogger(log.Named("pin"))) + if err != nil { + t.Fatal(err) + } + + initialSettings := sm.Settings() + pin := pin.PinnedSettings{ + Currency: "usd", + + Threshold: 0.1, + Storage: 1.0, + Ingress: 0, + Egress: 0, + MaxCollateral: 0, + } + + // only storage is pinned + if err := pm.Update(context.Background(), pin); err != nil { + t.Fatal(err) + } + + currentSettings := sm.Settings() + if err := checkSettings(currentSettings, pin, 1); err != nil { + t.Fatal(err) + } else if !currentSettings.MaxCollateral.Equals(initialSettings.MaxCollateral) { + t.Fatalf("expected max collateral to be %d, got %d", initialSettings.MaxCollateral, currentSettings.MaxCollateral) + } else if !currentSettings.IngressPrice.Equals(initialSettings.IngressPrice) { + t.Fatalf("expected ingress price to be %d, got %d", initialSettings.IngressPrice, currentSettings.IngressPrice) + } else if !currentSettings.EgressPrice.Equals(initialSettings.EgressPrice) { + t.Fatalf("expected egress price to be %d, got %d", initialSettings.EgressPrice, currentSettings.EgressPrice) + } + + // pin ingress + pin.Ingress = 1.0 + if err := pm.Update(context.Background(), pin); err != nil { + t.Fatal(err) + } + + currentSettings = sm.Settings() + if err := checkSettings(currentSettings, pin, 1); err != nil { + t.Fatal(err) + } else if !currentSettings.MaxCollateral.Equals(initialSettings.MaxCollateral) { + t.Fatalf("expected max collateral to be %d, got %d", initialSettings.MaxCollateral, currentSettings.MaxCollateral) + } else if !currentSettings.EgressPrice.Equals(initialSettings.EgressPrice) { + t.Fatalf("expected egress price to be %d, got %d", initialSettings.EgressPrice, currentSettings.EgressPrice) + } + + // pin egress + pin.Egress = 1.0 + if err := pm.Update(context.Background(), pin); err != nil { + t.Fatal(err) + } + + currentSettings = sm.Settings() + if err := checkSettings(currentSettings, pin, 1); err != nil { + t.Fatal(err) + } else if !currentSettings.MaxCollateral.Equals(initialSettings.MaxCollateral) { + t.Fatalf("expected max collateral to be %d, got %d", initialSettings.MaxCollateral, currentSettings.MaxCollateral) + } + + // pin max collateral + pin.MaxCollateral = 1.0 + if err := pm.Update(context.Background(), pin); err != nil { + t.Fatal(err) + } else if err := checkSettings(sm.Settings(), pin, 1); err != nil { + t.Fatal(err) + } +} + +func TestAutomaticUpdate(t *testing.T) { + log := zaptest.NewLogger(t) + db, err := sqlite.OpenDatabase(filepath.Join(t.TempDir(), "test.db"), log) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + rr := &exchangeRateRetrieverStub{ + value: 1, + currency: "usd", + } + + node, err := test.NewNode(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer node.Close() + + sm, err := settings.NewConfigManager(settings.WithHostKey(types.GeneratePrivateKey()), settings.WithStore(db), settings.WithChainManager(node.ChainManager())) + if err != nil { + t.Fatal(err) + } + defer sm.Close() + + pm, err := pin.NewManager(pin.WithAverageRateWindow(time.Second/2), + pin.WithFrequency(100*time.Millisecond), + pin.WithExchangeRateRetriever(rr), + pin.WithSettings(sm), + pin.WithStore(db), + pin.WithLogger(log.Named("pin"))) + if err != nil { + t.Fatal(err) + } + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + if err := pm.Run(ctx); err != nil { + if errors.Is(err, context.Canceled) { + return + } + panic(err) + } + }() + + time.Sleep(time.Second) + + pin := pin.PinnedSettings{ + Currency: "usd", + + Threshold: 1.0, + Storage: 1.0, + Ingress: 1.0, + Egress: 1.0, + MaxCollateral: 1.0, + } + + // check that the settings have not changed + if err := checkSettings(sm.Settings(), pin, 1); err == nil { + t.Fatal("expected settings to not be updated") + } + + // pin the settings + if err := pm.Update(context.Background(), pin); err != nil { + t.Fatal(err) + } else if err := checkSettings(sm.Settings(), pin, 1); err != nil { + t.Fatal(err) + } + + // update the exchange rate below the threshold + rr.updateRate(1.5) + time.Sleep(time.Second) + if err := checkSettings(sm.Settings(), pin, 1); err != nil { + t.Fatal(err) + } + + // update the exchange rate to put it over the threshold + rr.updateRate(2) + time.Sleep(time.Second) + if err := checkSettings(sm.Settings(), pin, 2); err != nil { + t.Fatal(err) + } + +} From 684299ef52b57cde532189d65ed26f70c15cca1b Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Sun, 10 Mar 2024 15:17:26 -0700 Subject: [PATCH 09/20] api: update pinner usage --- api/api.go | 2 +- api/endpoints.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/api.go b/api/api.go index 3a6832b1..cc0b9495 100644 --- a/api/api.go +++ b/api/api.go @@ -49,7 +49,7 @@ type ( // PinnedSettings updates and retrieves the host's pinned settings PinnedSettings interface { - Update(p pin.PinnedSettings) error + Update(context.Context, pin.PinnedSettings) error Pinned() pin.PinnedSettings } diff --git a/api/endpoints.go b/api/endpoints.go index 59e5e15c..5869f704 100644 --- a/api/endpoints.go +++ b/api/endpoints.go @@ -219,7 +219,7 @@ func (a *api) handlePUTPinnedSettings(c jape.Context) { return } - a.checkServerError(c, "failed to update pinned settings", a.pinned.Update(req)) + a.checkServerError(c, "failed to update pinned settings", a.pinned.Update(c.Request.Context(), req)) } func (a *api) handlePUTDDNSUpdate(c jape.Context) { From f4e99a05fad3f5f34fcb6e2fe3a0acbb56e5990e Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Sun, 10 Mar 2024 15:18:00 -0700 Subject: [PATCH 10/20] explorer: use float64 for exchange rate interface --- internal/explorer/explorer.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/explorer/explorer.go b/internal/explorer/explorer.go index 497e21d0..73588508 100644 --- a/internal/explorer/explorer.go +++ b/internal/explorer/explorer.go @@ -9,8 +9,6 @@ import ( "io" "net/http" "time" - - "github.com/shopspring/decimal" ) // An Explorer retrieves data about the Sia network from an external source. @@ -62,7 +60,7 @@ func makeRequest(ctx context.Context, method, url string, requestBody, response } // SiacoinExchangeRate returns the exchange rate for the given currency. -func (e *Explorer) SiacoinExchangeRate(ctx context.Context, currency string) (rate decimal.Decimal, err error) { +func (e *Explorer) SiacoinExchangeRate(ctx context.Context, currency string) (rate float64, err error) { err = makeRequest(ctx, http.MethodGet, fmt.Sprintf("%s/exchange-rate/siacoin/%s", e.url, currency), nil, &rate) return } From 9fc053dc1ee04c967913d01bcdf9a9d67c32a107 Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Sun, 10 Mar 2024 15:18:19 -0700 Subject: [PATCH 11/20] cmd: update pinner usage --- cmd/hostd/node.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cmd/hostd/node.go b/cmd/hostd/node.go index edb160c7..2e4139bb 100644 --- a/cmd/hostd/node.go +++ b/cmd/hostd/node.go @@ -7,7 +7,6 @@ import ( "os" "path/filepath" "strings" - "time" "go.sia.tech/core/types" "go.sia.tech/hostd/alerts" @@ -190,7 +189,11 @@ func newNode(ctx context.Context, walletKey types.PrivateKey, logger *zap.Logger if !cfg.Explorer.Disable { ex := explorer.New(cfg.Explorer.URL) - pm, err = pin.NewManager(5*time.Minute, db, ex, sr, logger.Named("pin")) + pm, err = pin.NewManager( + pin.WithStore(db), + pin.WithSettings(sr), + pin.WithExchangeRateRetriever(ex), + pin.WithLogger(logger.Named("pin"))) if err != nil { return nil, types.PrivateKey{}, fmt.Errorf("failed to create pin manager: %w", err) } From cfdad88e00f2aeddf710c45df49eb4beb16cae56 Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Sun, 10 Mar 2024 15:20:36 -0700 Subject: [PATCH 12/20] pin: fix lint --- host/settings/pin/options.go | 1 + host/settings/pin/pin_test.go | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/host/settings/pin/options.go b/host/settings/pin/options.go index ea758223..d0dbf8f2 100644 --- a/host/settings/pin/options.go +++ b/host/settings/pin/options.go @@ -6,6 +6,7 @@ import ( "go.uber.org/zap" ) +// An Option is a functional option for configuring a pin Manager. type Option func(*Manager) // WithLogger sets the logger for the manager. diff --git a/host/settings/pin/pin_test.go b/host/settings/pin/pin_test.go index e2ad7a5f..c09cdc52 100644 --- a/host/settings/pin/pin_test.go +++ b/host/settings/pin/pin_test.go @@ -266,5 +266,4 @@ func TestAutomaticUpdate(t *testing.T) { if err := checkSettings(sm.Settings(), pin, 2); err != nil { t.Fatal(err) } - } From 3b146a4d8759108e802b37ce3e76d5ac486c9091 Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Sun, 10 Mar 2024 15:35:38 -0700 Subject: [PATCH 13/20] sqlite: fix missing migration column --- persist/sqlite/migrations.go | 1 + 1 file changed, 1 insertion(+) diff --git a/persist/sqlite/migrations.go b/persist/sqlite/migrations.go index 725be36f..32474dfc 100644 --- a/persist/sqlite/migrations.go +++ b/persist/sqlite/migrations.go @@ -15,6 +15,7 @@ func migrateVersion26(tx txn, _ *zap.Logger) error { _, err := tx.Exec(`CREATE TABLE host_pinned_settings ( id INTEGER PRIMARY KEY NOT NULL DEFAULT 0 CHECK (id = 0), -- enforce a single row currency TEXT NOT NULL, + threshold REAL NOT NULL, storage_price REAL NOT NULL, ingress_price REAL NOT NULL, egress_price REAL NOT NULL, From a1650aae7a3d7a706ce48ef3f14c1914ca071a2f Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Mon, 11 Mar 2024 18:26:23 -0700 Subject: [PATCH 14/20] pin: add conversion test --- host/settings/pin/pin.go | 48 ++++++++++++++++++-------- host/settings/pin/pin_test.go | 63 +++++++++++++++++++++++++---------- persist/sqlite/settings.go | 7 ++-- 3 files changed, 83 insertions(+), 35 deletions(-) diff --git a/host/settings/pin/pin.go b/host/settings/pin/pin.go index 2d6e4d0f..584d2aaa 100644 --- a/host/settings/pin/pin.go +++ b/host/settings/pin/pin.go @@ -2,6 +2,7 @@ package pin import ( "context" + "errors" "fmt" "sync" "time" @@ -79,15 +80,6 @@ func isOverThreshold(a, b, percentage decimal.Decimal) bool { return diff.GreaterThanOrEqual(threshold) } -func convertToCurrency(target decimal.Decimal, rate decimal.Decimal) types.Currency { - hastings := target.Div(rate).Mul(decimal.New(1, 24)).Round(0).String() - c, err := types.ParseCurrency(hastings) - if err != nil { - panic(err) - } - return c -} - func averageRate(rates []decimal.Decimal) decimal.Decimal { var sum decimal.Decimal for _, r := range rates { @@ -96,6 +88,20 @@ func averageRate(rates []decimal.Decimal) decimal.Decimal { return sum.Div(decimal.NewFromInt(int64(len(rates)))) } +func CurrencyToSiacoins(target decimal.Decimal, rate decimal.Decimal) (types.Currency, error) { + if rate.IsZero() { + return types.Currency{}, nil + } + + i := target.Div(rate).Mul(decimal.New(1, 24)).BigInt() + if i.Sign() < 0 { + return types.Currency{}, errors.New("negative currency") + } else if i.BitLen() > 128 { + return types.Currency{}, errors.New("currency overflow") + } + return types.NewCurrency(i.Uint64(), i.Rsh(i, 64).Uint64()), nil +} + func (m *Manager) updatePrices(ctx context.Context, force bool) error { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() @@ -142,19 +148,35 @@ func (m *Manager) updatePrices(ctx context.Context, force bool) error { settings := m.sm.Settings() if m.pinned.Storage > 0 { - settings.StoragePrice = convertToCurrency(decimal.NewFromFloat(m.pinned.Storage), avgRate).Div64(4320).Div64(1e12) + value, err := CurrencyToSiacoins(decimal.NewFromFloat(m.pinned.Storage), avgRate) + if err != nil { + return fmt.Errorf("failed to convert storage price: %w", err) + } + settings.StoragePrice = value.Div64(4320).Div64(1e12) } if m.pinned.Ingress > 0 { - settings.IngressPrice = convertToCurrency(decimal.NewFromFloat(m.pinned.Ingress), avgRate).Div64(1e12) + value, err := CurrencyToSiacoins(decimal.NewFromFloat(m.pinned.Ingress), avgRate) + if err != nil { + return fmt.Errorf("failed to convert ingress price: %w", err) + } + settings.IngressPrice = value.Div64(1e12) } if m.pinned.Egress > 0 { - settings.EgressPrice = convertToCurrency(decimal.NewFromFloat(m.pinned.Egress), avgRate).Div64(1e12) + value, err := CurrencyToSiacoins(decimal.NewFromFloat(m.pinned.Egress), avgRate) + if err != nil { + return fmt.Errorf("failed to convert egress price: %w", err) + } + settings.EgressPrice = value.Div64(1e12) } if m.pinned.MaxCollateral > 0 { - settings.MaxCollateral = convertToCurrency(decimal.NewFromFloat(m.pinned.MaxCollateral), avgRate) + value, err := CurrencyToSiacoins(decimal.NewFromFloat(m.pinned.MaxCollateral), avgRate) + if err != nil { + return fmt.Errorf("failed to convert max collateral: %w", err) + } + settings.MaxCollateral = value } if err := m.sm.UpdateSettings(settings); err != nil { diff --git a/host/settings/pin/pin_test.go b/host/settings/pin/pin_test.go index c09cdc52..165220c1 100644 --- a/host/settings/pin/pin_test.go +++ b/host/settings/pin/pin_test.go @@ -41,47 +41,76 @@ func (e *exchangeRateRetrieverStub) SiacoinExchangeRate(_ context.Context, curre return e.value, nil } -func convertToCurrency(target decimal.Decimal, rate decimal.Decimal) types.Currency { - hastings := target.Div(rate).Mul(decimal.New(1, 24)).Round(0).String() - c, err := types.ParseCurrency(hastings) - if err != nil { - panic(err) - } - return c -} - func checkSettings(settings settings.Settings, pinned pin.PinnedSettings, expectedRate float64) error { rate := decimal.NewFromFloat(expectedRate) if pinned.Storage > 0 { - storagePrice := convertToCurrency(decimal.NewFromFloat(pinned.Storage), rate).Div64(4320).Div64(1e12) - if !storagePrice.Equals(settings.StoragePrice) { + storagePrice, err := pin.CurrencyToSiacoins(decimal.NewFromFloat(pinned.Storage), rate) + if err != nil { + return fmt.Errorf("failed to convert storage price: %v", err) + } else if !storagePrice.Div64(4320).Div64(1e12).Equals(settings.StoragePrice) { return fmt.Errorf("expected storage price %d, got %d", storagePrice, settings.StoragePrice) } } if pinned.Ingress > 0 { - ingressPrice := convertToCurrency(decimal.NewFromFloat(pinned.Ingress), rate).Div64(1e12) - if !ingressPrice.Equals(settings.IngressPrice) { + ingressPrice, err := pin.CurrencyToSiacoins(decimal.NewFromFloat(pinned.Ingress), rate) + if err != nil { + return fmt.Errorf("failed to convert storage price: %v", err) + } else if !ingressPrice.Div64(1e12).Equals(settings.IngressPrice) { return fmt.Errorf("expected ingress price %d, got %d", ingressPrice, settings.IngressPrice) } } if pinned.Egress > 0 { - egressPrice := convertToCurrency(decimal.NewFromFloat(pinned.Egress), rate).Div64(1e12) - if !egressPrice.Equals(settings.EgressPrice) { + egressPrice, err := pin.CurrencyToSiacoins(decimal.NewFromFloat(pinned.Egress), rate) + if err != nil { + return fmt.Errorf("failed to convert storage price: %v", err) + } else if !egressPrice.Div64(1e12).Equals(settings.EgressPrice) { return fmt.Errorf("expected egress price %d, got %d", egressPrice, settings.EgressPrice) } } if pinned.MaxCollateral > 0 { - maxCollateral := convertToCurrency(decimal.NewFromFloat(pinned.MaxCollateral), rate) - if !maxCollateral.Equals(settings.MaxCollateral) { + maxCollateral, err := pin.CurrencyToSiacoins(decimal.NewFromFloat(pinned.MaxCollateral), rate) + if err != nil { + return fmt.Errorf("failed to convert storage price: %v", err) + } else if !maxCollateral.Equals(settings.MaxCollateral) { return fmt.Errorf("expected max collateral %d, got %d", maxCollateral, settings.MaxCollateral) } } return nil } +func TestConvertCurrencyToSiacoins(t *testing.T) { + tests := []struct { + target decimal.Decimal + rate decimal.Decimal + expected types.Currency + err error + }{ + {decimal.NewFromFloat(1), decimal.NewFromFloat(1), types.Siacoins(1), nil}, + {decimal.NewFromFloat(1), decimal.NewFromFloat(2), types.Siacoins(1).Div64(2), nil}, + {decimal.NewFromFloat(1), decimal.NewFromFloat(0.5), types.Siacoins(2), nil}, + {decimal.NewFromFloat(0.5), decimal.NewFromFloat(0.5), types.Siacoins(1), nil}, + {decimal.NewFromFloat(1), decimal.NewFromFloat(0.001), types.Siacoins(1000), nil}, + {decimal.NewFromFloat(1), decimal.NewFromFloat(0), types.Currency{}, nil}, + {decimal.NewFromFloat(1), decimal.NewFromFloat(-1), types.Currency{}, errors.New("negative currency")}, + {decimal.NewFromFloat(-1), decimal.NewFromFloat(1), types.Currency{}, errors.New("negative currency")}, + {decimal.New(1, 50), decimal.NewFromFloat(0.1), types.Currency{}, errors.New("currency overflow")}, + } + for i, test := range tests { + if result, err := pin.CurrencyToSiacoins(test.target, test.rate); test.err != nil { + if err == nil { + t.Fatalf("%d: expected error, got nil", i) + } else if err.Error() != test.err.Error() { + t.Fatalf("%d: expected %v, got %v", i, test.err, err) + } + } else if !test.expected.Equals(result) { + t.Fatalf("%d: expected %d, got %d", i, test.expected, result) + } + } +} + func TestPinnedFields(t *testing.T) { log := zaptest.NewLogger(t) db, err := sqlite.OpenDatabase(filepath.Join(t.TempDir(), "test.db"), log) diff --git a/persist/sqlite/settings.go b/persist/sqlite/settings.go index bb8d9505..1bc35a8f 100644 --- a/persist/sqlite/settings.go +++ b/persist/sqlite/settings.go @@ -30,11 +30,8 @@ FROM host_pinned_settings;` // UpdatePinnedSettings updates the host's pinned settings. func (s *Store) UpdatePinnedSettings(p pin.PinnedSettings) error { const query = `INSERT INTO host_pinned_settings (id, currency, threshold, storage_price, ingress_price, egress_price, max_collateral) VALUES (0, $1, $2, $3, $4, $5, $6) -ON CONFLICT (id) DO UPDATE SET currency=EXCLUDED.currency, threshold=EXCLUDED.threshold, storage_price=EXCLUDED.storage_price, ingress_price=EXCLUDED.ingress_price, egress_price=EXCLUDED.egress_price, max_collateral=EXCLUDED.max_collateral -RETURNING id;` - - var dummyID int64 - err := s.queryRow(query, p.Currency, p.Threshold, p.Storage, p.Ingress, p.Egress, p.MaxCollateral).Scan(&dummyID) +ON CONFLICT (id) DO UPDATE SET currency=EXCLUDED.currency, threshold=EXCLUDED.threshold, storage_price=EXCLUDED.storage_price, ingress_price=EXCLUDED.ingress_price, egress_price=EXCLUDED.egress_price, max_collateral=EXCLUDED.max_collateral;` + _, err := s.exec(query, p.Currency, p.Threshold, p.Storage, p.Ingress, p.Egress, p.MaxCollateral) return err } From a7e1ac3dd12c6514fa28e2af7296ac6e9f83c9f7 Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Mon, 11 Mar 2024 18:43:45 -0700 Subject: [PATCH 15/20] pin,sqlite: explicitly enable/disable pinned fields --- host/settings/pin/pin.go | 66 +++++++++++++++++++-------------- host/settings/pin/pin_test.go | 66 ++++++++++++++++++++++----------- persist/sqlite/init.sql | 4 ++ persist/sqlite/migrations.go | 20 ++++++---- persist/sqlite/settings.go | 14 ++++--- persist/sqlite/settings_test.go | 33 +++++++++++++++++ 6 files changed, 141 insertions(+), 62 deletions(-) diff --git a/host/settings/pin/pin.go b/host/settings/pin/pin.go index 584d2aaa..66ee3a42 100644 --- a/host/settings/pin/pin.go +++ b/host/settings/pin/pin.go @@ -14,6 +14,12 @@ import ( ) type ( + // A Pin is a pinned price in an external currency. + Pin struct { + Pinned bool `json:"pinned"` + Value float64 `json:"value"` + } + // PinnedSettings contains the settings that can be optionally // pinned to an external currency. This uses an external explorer // to retrieve the current exchange rate. @@ -29,13 +35,13 @@ type ( // Storage, Ingress, and Egress are the pinned prices in the // external currency. - Storage float64 `json:"storage"` - Ingress float64 `json:"ingress"` - Egress float64 `json:"egress"` + Storage Pin `json:"storage"` + Ingress Pin `json:"ingress"` + Egress Pin `json:"egress"` // MaxCollateral is the maximum collateral that the host will // accept in the external currency. - MaxCollateral float64 `json:"maxCollateral"` + MaxCollateral Pin `json:"maxCollateral"` } // A SettingsManager updates and retrieves the host's settings. @@ -70,10 +76,14 @@ type ( mu sync.Mutex rates []decimal.Decimal lastRate decimal.Decimal - pinned PinnedSettings // in-memory cache of pinned settings + settings PinnedSettings // in-memory cache of pinned settings } ) +func (p Pin) IsPinned() bool { + return p.Pinned && p.Value > 0 +} + func isOverThreshold(a, b, percentage decimal.Decimal) bool { threshold := a.Mul(percentage) diff := a.Sub(b).Abs() @@ -107,7 +117,7 @@ func (m *Manager) updatePrices(ctx context.Context, force bool) error { defer cancel() m.mu.Lock() - currency := m.pinned.Currency + currency := m.settings.Currency m.mu.Unlock() if currency == "" { @@ -132,12 +142,12 @@ func (m *Manager) updatePrices(ctx context.Context, force bool) error { } // skip updating prices if the pinned settings are zero - if m.pinned.Storage <= 0 && m.pinned.Ingress <= 0 && m.pinned.Egress <= 0 && m.pinned.MaxCollateral <= 0 { + if !m.settings.Storage.IsPinned() && !m.settings.Ingress.IsPinned() && !m.settings.Egress.IsPinned() && !m.settings.MaxCollateral.IsPinned() { return nil } avgRate := averageRate(m.rates) - threshold := decimal.NewFromFloat(m.pinned.Threshold) + threshold := decimal.NewFromFloat(m.settings.Threshold) log := m.log.With(zap.String("currency", currency), zap.Stringer("threshold", threshold), zap.Stringer("current", current), zap.Stringer("average", avgRate), zap.Stringer("last", m.lastRate)) if !force && !isOverThreshold(m.lastRate, avgRate, threshold) { @@ -147,32 +157,32 @@ func (m *Manager) updatePrices(ctx context.Context, force bool) error { m.lastRate = avgRate settings := m.sm.Settings() - if m.pinned.Storage > 0 { - value, err := CurrencyToSiacoins(decimal.NewFromFloat(m.pinned.Storage), avgRate) + if m.settings.Storage.IsPinned() { + value, err := CurrencyToSiacoins(decimal.NewFromFloat(m.settings.Storage.Value), avgRate) if err != nil { return fmt.Errorf("failed to convert storage price: %w", err) } settings.StoragePrice = value.Div64(4320).Div64(1e12) } - if m.pinned.Ingress > 0 { - value, err := CurrencyToSiacoins(decimal.NewFromFloat(m.pinned.Ingress), avgRate) + if m.settings.Ingress.IsPinned() { + value, err := CurrencyToSiacoins(decimal.NewFromFloat(m.settings.Ingress.Value), avgRate) if err != nil { return fmt.Errorf("failed to convert ingress price: %w", err) } settings.IngressPrice = value.Div64(1e12) } - if m.pinned.Egress > 0 { - value, err := CurrencyToSiacoins(decimal.NewFromFloat(m.pinned.Egress), avgRate) + if m.settings.Egress.IsPinned() { + value, err := CurrencyToSiacoins(decimal.NewFromFloat(m.settings.Egress.Value), avgRate) if err != nil { return fmt.Errorf("failed to convert egress price: %w", err) } settings.EgressPrice = value.Div64(1e12) } - if m.pinned.MaxCollateral > 0 { - value, err := CurrencyToSiacoins(decimal.NewFromFloat(m.pinned.MaxCollateral), avgRate) + if m.settings.MaxCollateral.IsPinned() { + value, err := CurrencyToSiacoins(decimal.NewFromFloat(m.settings.MaxCollateral.Value), avgRate) if err != nil { return fmt.Errorf("failed to convert max collateral: %w", err) } @@ -190,7 +200,7 @@ func (m *Manager) updatePrices(ctx context.Context, force bool) error { func (m *Manager) Pinned() PinnedSettings { m.mu.Lock() defer m.mu.Unlock() - return m.pinned + return m.settings } // Update updates the host's pinned settings. @@ -200,21 +210,21 @@ func (m *Manager) Update(ctx context.Context, p PinnedSettings) error { return fmt.Errorf("currency must be set") case p.Threshold < 0 || p.Threshold > 1: return fmt.Errorf("threshold must be between 0 and 1") - case p.Storage < 0: - return fmt.Errorf("storage price must be non-negative") - case p.Ingress < 0: - return fmt.Errorf("ingress price must be non-negative") - case p.Egress < 0: - return fmt.Errorf("egress price must be non-negative") - case p.MaxCollateral < 0: - return fmt.Errorf("max collateral must be non-negative") + case p.Storage.Pinned && p.Storage.Value <= 0: + return fmt.Errorf("storage price must be greater than 0") + case p.Ingress.Pinned && p.Ingress.Value <= 0: + return fmt.Errorf("ingress price must be greater than 0") + case p.Egress.Pinned && p.Egress.Value <= 0: + return fmt.Errorf("egress price must be greater than 0") + case p.MaxCollateral.Pinned && p.MaxCollateral.Value <= 0: + return fmt.Errorf("max collateral must be greater than 0") } m.mu.Lock() - if m.pinned.Currency != p.Currency { + if m.settings.Currency != p.Currency { m.rates = m.rates[:0] // currency has changed, reset rates } - m.pinned = p + m.settings = p m.mu.Unlock() if err := m.store.UpdatePinnedSettings(p); err != nil { return fmt.Errorf("failed to update pinned settings: %w", err) @@ -279,6 +289,6 @@ func NewManager(opts ...Option) (*Manager, error) { if err != nil { return nil, fmt.Errorf("failed to get pinned settings: %w", err) } - m.pinned = pinned + m.settings = pinned return m, nil } diff --git a/host/settings/pin/pin_test.go b/host/settings/pin/pin_test.go index 165220c1..c2367bfc 100644 --- a/host/settings/pin/pin_test.go +++ b/host/settings/pin/pin_test.go @@ -43,8 +43,8 @@ func (e *exchangeRateRetrieverStub) SiacoinExchangeRate(_ context.Context, curre func checkSettings(settings settings.Settings, pinned pin.PinnedSettings, expectedRate float64) error { rate := decimal.NewFromFloat(expectedRate) - if pinned.Storage > 0 { - storagePrice, err := pin.CurrencyToSiacoins(decimal.NewFromFloat(pinned.Storage), rate) + if pinned.Storage.IsPinned() { + storagePrice, err := pin.CurrencyToSiacoins(decimal.NewFromFloat(pinned.Storage.Value), rate) if err != nil { return fmt.Errorf("failed to convert storage price: %v", err) } else if !storagePrice.Div64(4320).Div64(1e12).Equals(settings.StoragePrice) { @@ -52,8 +52,8 @@ func checkSettings(settings settings.Settings, pinned pin.PinnedSettings, expect } } - if pinned.Ingress > 0 { - ingressPrice, err := pin.CurrencyToSiacoins(decimal.NewFromFloat(pinned.Ingress), rate) + if pinned.Ingress.IsPinned() { + ingressPrice, err := pin.CurrencyToSiacoins(decimal.NewFromFloat(pinned.Ingress.Value), rate) if err != nil { return fmt.Errorf("failed to convert storage price: %v", err) } else if !ingressPrice.Div64(1e12).Equals(settings.IngressPrice) { @@ -61,8 +61,8 @@ func checkSettings(settings settings.Settings, pinned pin.PinnedSettings, expect } } - if pinned.Egress > 0 { - egressPrice, err := pin.CurrencyToSiacoins(decimal.NewFromFloat(pinned.Egress), rate) + if pinned.Egress.IsPinned() { + egressPrice, err := pin.CurrencyToSiacoins(decimal.NewFromFloat(pinned.Egress.Value), rate) if err != nil { return fmt.Errorf("failed to convert storage price: %v", err) } else if !egressPrice.Div64(1e12).Equals(settings.EgressPrice) { @@ -70,8 +70,8 @@ func checkSettings(settings settings.Settings, pinned pin.PinnedSettings, expect } } - if pinned.MaxCollateral > 0 { - maxCollateral, err := pin.CurrencyToSiacoins(decimal.NewFromFloat(pinned.MaxCollateral), rate) + if pinned.MaxCollateral.IsPinned() { + maxCollateral, err := pin.CurrencyToSiacoins(decimal.NewFromFloat(pinned.MaxCollateral.Value), rate) if err != nil { return fmt.Errorf("failed to convert storage price: %v", err) } else if !maxCollateral.Equals(settings.MaxCollateral) { @@ -150,11 +150,23 @@ func TestPinnedFields(t *testing.T) { pin := pin.PinnedSettings{ Currency: "usd", - Threshold: 0.1, - Storage: 1.0, - Ingress: 0, - Egress: 0, - MaxCollateral: 0, + Threshold: 0.1, + Storage: pin.Pin{ + Pinned: true, + Value: 1.0, + }, + Ingress: pin.Pin{ + Pinned: false, + Value: 1.0, + }, + Egress: pin.Pin{ + Pinned: false, + Value: 1.0, + }, + MaxCollateral: pin.Pin{ + Pinned: false, + Value: 1.0, + }, } // only storage is pinned @@ -174,7 +186,7 @@ func TestPinnedFields(t *testing.T) { } // pin ingress - pin.Ingress = 1.0 + pin.Ingress.Pinned = true if err := pm.Update(context.Background(), pin); err != nil { t.Fatal(err) } @@ -189,7 +201,7 @@ func TestPinnedFields(t *testing.T) { } // pin egress - pin.Egress = 1.0 + pin.Egress.Pinned = true if err := pm.Update(context.Background(), pin); err != nil { t.Fatal(err) } @@ -202,7 +214,7 @@ func TestPinnedFields(t *testing.T) { } // pin max collateral - pin.MaxCollateral = 1.0 + pin.MaxCollateral.Pinned = true if err := pm.Update(context.Background(), pin); err != nil { t.Fatal(err) } else if err := checkSettings(sm.Settings(), pin, 1); err != nil { @@ -263,11 +275,23 @@ func TestAutomaticUpdate(t *testing.T) { pin := pin.PinnedSettings{ Currency: "usd", - Threshold: 1.0, - Storage: 1.0, - Ingress: 1.0, - Egress: 1.0, - MaxCollateral: 1.0, + Threshold: 1.0, + Storage: pin.Pin{ + Pinned: true, + Value: 1.0, + }, + Ingress: pin.Pin{ + Pinned: true, + Value: 1.0, + }, + Egress: pin.Pin{ + Pinned: true, + Value: 1.0, + }, + MaxCollateral: pin.Pin{ + Pinned: true, + Value: 1.0, + }, } // check that the settings have not changed diff --git a/persist/sqlite/init.sql b/persist/sqlite/init.sql index 5241c7a0..53e22486 100644 --- a/persist/sqlite/init.sql +++ b/persist/sqlite/init.sql @@ -200,9 +200,13 @@ CREATE TABLE host_pinned_settings ( id INTEGER PRIMARY KEY NOT NULL DEFAULT 0 CHECK (id = 0), -- enforce a single row currency TEXT NOT NULL, threshold REAL NOT NULL, + storage_pinned BOOLEAN NOT NULL, storage_price REAL NOT NULL, + ingress_pinned BOOLEAN NOT NULL, ingress_price REAL NOT NULL, + egress_pinned BOOLEAN NOT NULL, egress_price REAL NOT NULL, + max_collateral_pinned BOOLEAN NOT NULL, max_collateral REAL NOT NULL ); diff --git a/persist/sqlite/migrations.go b/persist/sqlite/migrations.go index 32474dfc..5869d5ef 100644 --- a/persist/sqlite/migrations.go +++ b/persist/sqlite/migrations.go @@ -13,14 +13,18 @@ import ( // migrateVersion26 creates the host_pinned_settings table. func migrateVersion26(tx txn, _ *zap.Logger) error { _, err := tx.Exec(`CREATE TABLE host_pinned_settings ( - id INTEGER PRIMARY KEY NOT NULL DEFAULT 0 CHECK (id = 0), -- enforce a single row - currency TEXT NOT NULL, - threshold REAL NOT NULL, - storage_price REAL NOT NULL, - ingress_price REAL NOT NULL, - egress_price REAL NOT NULL, - max_collateral REAL NOT NULL - );`) + id INTEGER PRIMARY KEY NOT NULL DEFAULT 0 CHECK (id = 0), -- enforce a single row + currency TEXT NOT NULL, + threshold REAL NOT NULL, + storage_pinned BOOLEAN NOT NULL, + storage_price REAL NOT NULL, + ingress_pinned BOOLEAN NOT NULL, + ingress_price REAL NOT NULL, + egress_pinned BOOLEAN NOT NULL, + egress_price REAL NOT NULL, + max_collateral_pinned BOOLEAN NOT NULL, + max_collateral REAL NOT NULL +);`) return err } diff --git a/persist/sqlite/settings.go b/persist/sqlite/settings.go index 1bc35a8f..237cdba8 100644 --- a/persist/sqlite/settings.go +++ b/persist/sqlite/settings.go @@ -17,10 +17,10 @@ import ( // PinnedSettings returns the host's pinned settings. func (s *Store) PinnedSettings() (pinned pin.PinnedSettings, err error) { - const query = `SELECT currency, threshold, storage_price, ingress_price, egress_price, max_collateral + const query = `SELECT currency, threshold, storage_pinned, storage_price, ingress_pinned, ingress_price, egress_pinned, egress_price, max_collateral_pinned, max_collateral FROM host_pinned_settings;` - err = s.queryRow(query).Scan(&pinned.Currency, &pinned.Threshold, &pinned.Storage, &pinned.Ingress, &pinned.Egress, &pinned.MaxCollateral) + err = s.queryRow(query).Scan(&pinned.Currency, &pinned.Threshold, &pinned.Storage.Pinned, &pinned.Storage.Value, &pinned.Ingress.Pinned, &pinned.Ingress.Value, &pinned.Egress.Pinned, &pinned.Egress.Value, &pinned.MaxCollateral.Pinned, &pinned.MaxCollateral.Value) if errors.Is(err, sql.ErrNoRows) { return pin.PinnedSettings{}, nil } @@ -29,9 +29,13 @@ FROM host_pinned_settings;` // UpdatePinnedSettings updates the host's pinned settings. func (s *Store) UpdatePinnedSettings(p pin.PinnedSettings) error { - const query = `INSERT INTO host_pinned_settings (id, currency, threshold, storage_price, ingress_price, egress_price, max_collateral) VALUES (0, $1, $2, $3, $4, $5, $6) -ON CONFLICT (id) DO UPDATE SET currency=EXCLUDED.currency, threshold=EXCLUDED.threshold, storage_price=EXCLUDED.storage_price, ingress_price=EXCLUDED.ingress_price, egress_price=EXCLUDED.egress_price, max_collateral=EXCLUDED.max_collateral;` - _, err := s.exec(query, p.Currency, p.Threshold, p.Storage, p.Ingress, p.Egress, p.MaxCollateral) + const query = `INSERT INTO host_pinned_settings (id, currency, threshold, storage_pinned, storage_price, ingress_pinned, ingress_price, egress_pinned, egress_price, max_collateral_pinned, max_collateral) +VALUES (0, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +ON CONFLICT (id) DO UPDATE SET currency=EXCLUDED.currency, threshold=EXCLUDED.threshold, +storage_pinned=EXCLUDED.storage_pinned, storage_price=EXCLUDED.storage_price, ingress_pinned=EXCLUDED.ingress_pinned, +ingress_price=EXCLUDED.ingress_price, egress_pinned=EXCLUDED.egress_pinned, egress_price=EXCLUDED.egress_price, +max_collateral_pinned=EXCLUDED.max_collateral_pinned, max_collateral=EXCLUDED.max_collateral;` + _, err := s.exec(query, p.Currency, p.Threshold, p.Storage.Pinned, p.Storage.Value, p.Ingress.Pinned, p.Ingress.Value, p.Egress.Pinned, p.Egress.Value, p.MaxCollateral.Pinned, p.MaxCollateral.Value) return err } diff --git a/persist/sqlite/settings_test.go b/persist/sqlite/settings_test.go index e7bffe1a..e7d0f0ac 100644 --- a/persist/sqlite/settings_test.go +++ b/persist/sqlite/settings_test.go @@ -11,6 +11,7 @@ import ( "go.sia.tech/core/types" "go.sia.tech/hostd/host/settings" + "go.sia.tech/hostd/host/settings/pin" "go.uber.org/zap/zaptest" "lukechampine.com/frand" ) @@ -37,6 +38,38 @@ func randomSettings() settings.Settings { } } +func randomPinnedSettings() pin.PinnedSettings { + return pin.PinnedSettings{ + Currency: hex.EncodeToString(frand.Bytes(3)), + Threshold: frand.Float64(), + Storage: pin.Pin{Pinned: frand.Intn(1) == 1, Value: frand.Float64()}, + Ingress: pin.Pin{Pinned: frand.Intn(1) == 1, Value: frand.Float64()}, + Egress: pin.Pin{Pinned: frand.Intn(1) == 1, Value: frand.Float64()}, + MaxCollateral: pin.Pin{Pinned: frand.Intn(1) == 1, Value: frand.Float64()}, + } +} + +func TestPinned(t *testing.T) { + log := zaptest.NewLogger(t) + db, err := OpenDatabase(filepath.Join(t.TempDir(), "hostdb.db"), log.Named("sqlite")) + if err != nil { + t.Fatal(err) + + } + defer db.Close() + + for i := 0; i < 10000; i++ { + p := randomPinnedSettings() + if err := db.UpdatePinnedSettings(p); err != nil { + t.Fatal(err) + } else if p2, err := db.PinnedSettings(); err != nil { + t.Fatal(err) + } else if !reflect.DeepEqual(p, p2) { + t.Fatalf("expected %v, got %v", p, p2) + } + } +} + func TestSettings(t *testing.T) { log := zaptest.NewLogger(t) db, err := OpenDatabase(filepath.Join(t.TempDir(), "hostdb.db"), log.Named("sqlite")) From 22c5c6ada9544b92e452ff1b5798f5c989d7b874 Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Mon, 11 Mar 2024 18:45:55 -0700 Subject: [PATCH 16/20] pin: rename conversion func --- host/settings/pin/pin.go | 10 +++++----- host/settings/pin/pin_test.go | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/host/settings/pin/pin.go b/host/settings/pin/pin.go index 66ee3a42..4d10896f 100644 --- a/host/settings/pin/pin.go +++ b/host/settings/pin/pin.go @@ -98,7 +98,7 @@ func averageRate(rates []decimal.Decimal) decimal.Decimal { return sum.Div(decimal.NewFromInt(int64(len(rates)))) } -func CurrencyToSiacoins(target decimal.Decimal, rate decimal.Decimal) (types.Currency, error) { +func ConvertCurrencyToSC(target decimal.Decimal, rate decimal.Decimal) (types.Currency, error) { if rate.IsZero() { return types.Currency{}, nil } @@ -158,7 +158,7 @@ func (m *Manager) updatePrices(ctx context.Context, force bool) error { settings := m.sm.Settings() if m.settings.Storage.IsPinned() { - value, err := CurrencyToSiacoins(decimal.NewFromFloat(m.settings.Storage.Value), avgRate) + value, err := ConvertCurrencyToSC(decimal.NewFromFloat(m.settings.Storage.Value), avgRate) if err != nil { return fmt.Errorf("failed to convert storage price: %w", err) } @@ -166,7 +166,7 @@ func (m *Manager) updatePrices(ctx context.Context, force bool) error { } if m.settings.Ingress.IsPinned() { - value, err := CurrencyToSiacoins(decimal.NewFromFloat(m.settings.Ingress.Value), avgRate) + value, err := ConvertCurrencyToSC(decimal.NewFromFloat(m.settings.Ingress.Value), avgRate) if err != nil { return fmt.Errorf("failed to convert ingress price: %w", err) } @@ -174,7 +174,7 @@ func (m *Manager) updatePrices(ctx context.Context, force bool) error { } if m.settings.Egress.IsPinned() { - value, err := CurrencyToSiacoins(decimal.NewFromFloat(m.settings.Egress.Value), avgRate) + value, err := ConvertCurrencyToSC(decimal.NewFromFloat(m.settings.Egress.Value), avgRate) if err != nil { return fmt.Errorf("failed to convert egress price: %w", err) } @@ -182,7 +182,7 @@ func (m *Manager) updatePrices(ctx context.Context, force bool) error { } if m.settings.MaxCollateral.IsPinned() { - value, err := CurrencyToSiacoins(decimal.NewFromFloat(m.settings.MaxCollateral.Value), avgRate) + value, err := ConvertCurrencyToSC(decimal.NewFromFloat(m.settings.MaxCollateral.Value), avgRate) if err != nil { return fmt.Errorf("failed to convert max collateral: %w", err) } diff --git a/host/settings/pin/pin_test.go b/host/settings/pin/pin_test.go index c2367bfc..883c8b25 100644 --- a/host/settings/pin/pin_test.go +++ b/host/settings/pin/pin_test.go @@ -44,7 +44,7 @@ func (e *exchangeRateRetrieverStub) SiacoinExchangeRate(_ context.Context, curre func checkSettings(settings settings.Settings, pinned pin.PinnedSettings, expectedRate float64) error { rate := decimal.NewFromFloat(expectedRate) if pinned.Storage.IsPinned() { - storagePrice, err := pin.CurrencyToSiacoins(decimal.NewFromFloat(pinned.Storage.Value), rate) + storagePrice, err := pin.ConvertCurrencyToSC(decimal.NewFromFloat(pinned.Storage.Value), rate) if err != nil { return fmt.Errorf("failed to convert storage price: %v", err) } else if !storagePrice.Div64(4320).Div64(1e12).Equals(settings.StoragePrice) { @@ -53,7 +53,7 @@ func checkSettings(settings settings.Settings, pinned pin.PinnedSettings, expect } if pinned.Ingress.IsPinned() { - ingressPrice, err := pin.CurrencyToSiacoins(decimal.NewFromFloat(pinned.Ingress.Value), rate) + ingressPrice, err := pin.ConvertCurrencyToSC(decimal.NewFromFloat(pinned.Ingress.Value), rate) if err != nil { return fmt.Errorf("failed to convert storage price: %v", err) } else if !ingressPrice.Div64(1e12).Equals(settings.IngressPrice) { @@ -62,7 +62,7 @@ func checkSettings(settings settings.Settings, pinned pin.PinnedSettings, expect } if pinned.Egress.IsPinned() { - egressPrice, err := pin.CurrencyToSiacoins(decimal.NewFromFloat(pinned.Egress.Value), rate) + egressPrice, err := pin.ConvertCurrencyToSC(decimal.NewFromFloat(pinned.Egress.Value), rate) if err != nil { return fmt.Errorf("failed to convert storage price: %v", err) } else if !egressPrice.Div64(1e12).Equals(settings.EgressPrice) { @@ -71,7 +71,7 @@ func checkSettings(settings settings.Settings, pinned pin.PinnedSettings, expect } if pinned.MaxCollateral.IsPinned() { - maxCollateral, err := pin.CurrencyToSiacoins(decimal.NewFromFloat(pinned.MaxCollateral.Value), rate) + maxCollateral, err := pin.ConvertCurrencyToSC(decimal.NewFromFloat(pinned.MaxCollateral.Value), rate) if err != nil { return fmt.Errorf("failed to convert storage price: %v", err) } else if !maxCollateral.Equals(settings.MaxCollateral) { @@ -81,7 +81,7 @@ func checkSettings(settings settings.Settings, pinned pin.PinnedSettings, expect return nil } -func TestConvertCurrencyToSiacoins(t *testing.T) { +func TestConvertConvertCurrencyToSC(t *testing.T) { tests := []struct { target decimal.Decimal rate decimal.Decimal @@ -99,7 +99,7 @@ func TestConvertCurrencyToSiacoins(t *testing.T) { {decimal.New(1, 50), decimal.NewFromFloat(0.1), types.Currency{}, errors.New("currency overflow")}, } for i, test := range tests { - if result, err := pin.CurrencyToSiacoins(test.target, test.rate); test.err != nil { + if result, err := pin.ConvertCurrencyToSC(test.target, test.rate); test.err != nil { if err == nil { t.Fatalf("%d: expected error, got nil", i) } else if err.Error() != test.err.Error() { From 1e91569fc571ea4185e8bf0ade61424476d7bece Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Mon, 11 Mar 2024 18:46:35 -0700 Subject: [PATCH 17/20] pin: fix lint --- host/settings/pin/pin.go | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/host/settings/pin/pin.go b/host/settings/pin/pin.go index 4d10896f..be1647f4 100644 --- a/host/settings/pin/pin.go +++ b/host/settings/pin/pin.go @@ -98,20 +98,6 @@ func averageRate(rates []decimal.Decimal) decimal.Decimal { return sum.Div(decimal.NewFromInt(int64(len(rates)))) } -func ConvertCurrencyToSC(target decimal.Decimal, rate decimal.Decimal) (types.Currency, error) { - if rate.IsZero() { - return types.Currency{}, nil - } - - i := target.Div(rate).Mul(decimal.New(1, 24)).BigInt() - if i.Sign() < 0 { - return types.Currency{}, errors.New("negative currency") - } else if i.BitLen() > 128 { - return types.Currency{}, errors.New("currency overflow") - } - return types.NewCurrency(i.Uint64(), i.Rsh(i, 64).Uint64()), nil -} - func (m *Manager) updatePrices(ctx context.Context, force bool) error { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() @@ -255,6 +241,22 @@ func (m *Manager) Run(ctx context.Context) error { } } +// ConvertCurrencyToSC converts a value in an external currency and an exchange +// rate to Siacoins. +func ConvertCurrencyToSC(target decimal.Decimal, rate decimal.Decimal) (types.Currency, error) { + if rate.IsZero() { + return types.Currency{}, nil + } + + i := target.Div(rate).Mul(decimal.New(1, 24)).BigInt() + if i.Sign() < 0 { + return types.Currency{}, errors.New("negative currency") + } else if i.BitLen() > 128 { + return types.Currency{}, errors.New("currency overflow") + } + return types.NewCurrency(i.Uint64(), i.Rsh(i, 64).Uint64()), nil +} + // NewManager creates a new pin manager. func NewManager(opts ...Option) (*Manager, error) { m := &Manager{ From fcebca1185cfd1eeeaa64e92aea13144b67c2023 Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Tue, 12 Mar 2024 07:27:27 -0700 Subject: [PATCH 18/20] api,cmd,sqlite: add context --- api/api.go | 2 +- api/endpoints.go | 2 +- cmd/hostd/main.go | 5 +---- host/settings/pin/pin.go | 10 +++++----- persist/sqlite/settings.go | 5 +++-- persist/sqlite/settings_test.go | 5 +++-- 6 files changed, 14 insertions(+), 15 deletions(-) diff --git a/api/api.go b/api/api.go index cc0b9495..8d7ca85b 100644 --- a/api/api.go +++ b/api/api.go @@ -50,7 +50,7 @@ type ( // PinnedSettings updates and retrieves the host's pinned settings PinnedSettings interface { Update(context.Context, pin.PinnedSettings) error - Pinned() pin.PinnedSettings + Pinned(context.Context) pin.PinnedSettings } // A MetricManager retrieves metrics related to the host diff --git a/api/endpoints.go b/api/endpoints.go index 5869f704..9979e096 100644 --- a/api/endpoints.go +++ b/api/endpoints.go @@ -205,7 +205,7 @@ func (a *api) handleGETPinnedSettings(c jape.Context) { c.Error(errors.New("pinned settings disabled"), http.StatusNotFound) return } - c.Encode(a.pinned.Pinned()) + c.Encode(a.pinned.Pinned(c.Request.Context())) } func (a *api) handlePUTPinnedSettings(c jape.Context) { diff --git a/cmd/hostd/main.go b/cmd/hostd/main.go index 26571a35..1c7513bf 100644 --- a/cmd/hostd/main.go +++ b/cmd/hostd/main.go @@ -377,10 +377,7 @@ func main() { api.ServerWithSettings(node.settings), api.ServerWithWallet(node.w), api.ServerWithLogger(log.Named("api")), - } - if node.pinned != nil { - // pinner should be nil if explorer data is disabled - opts = append(opts, api.ServerWithPinnedSettings(node.pinned)) + api.ServerWithPinnedSettings(node.pinned), } auth := jape.BasicAuth(cfg.HTTP.Password) diff --git a/host/settings/pin/pin.go b/host/settings/pin/pin.go index be1647f4..1e9ad9d6 100644 --- a/host/settings/pin/pin.go +++ b/host/settings/pin/pin.go @@ -52,8 +52,8 @@ type ( // A Store stores and retrieves pinned settings. Store interface { - PinnedSettings() (PinnedSettings, error) - UpdatePinnedSettings(PinnedSettings) error + PinnedSettings(context.Context) (PinnedSettings, error) + UpdatePinnedSettings(context.Context, PinnedSettings) error } // An ExchangeRateRetriever retrieves the current exchange rate from @@ -183,7 +183,7 @@ func (m *Manager) updatePrices(ctx context.Context, force bool) error { } // Pinned returns the host's pinned settings. -func (m *Manager) Pinned() PinnedSettings { +func (m *Manager) Pinned(context.Context) PinnedSettings { m.mu.Lock() defer m.mu.Unlock() return m.settings @@ -212,7 +212,7 @@ func (m *Manager) Update(ctx context.Context, p PinnedSettings) error { } m.settings = p m.mu.Unlock() - if err := m.store.UpdatePinnedSettings(p); err != nil { + if err := m.store.UpdatePinnedSettings(ctx, p); err != nil { return fmt.Errorf("failed to update pinned settings: %w", err) } else if err := m.updatePrices(ctx, true); err != nil { return fmt.Errorf("failed to update prices: %w", err) @@ -287,7 +287,7 @@ func NewManager(opts ...Option) (*Manager, error) { } // load the current pinned settings - pinned, err := m.store.PinnedSettings() + pinned, err := m.store.PinnedSettings(context.Background()) if err != nil { return nil, fmt.Errorf("failed to get pinned settings: %w", err) } diff --git a/persist/sqlite/settings.go b/persist/sqlite/settings.go index 237cdba8..aec9734a 100644 --- a/persist/sqlite/settings.go +++ b/persist/sqlite/settings.go @@ -1,6 +1,7 @@ package sqlite import ( + "context" "crypto/ed25519" "database/sql" "encoding/json" @@ -16,7 +17,7 @@ import ( ) // PinnedSettings returns the host's pinned settings. -func (s *Store) PinnedSettings() (pinned pin.PinnedSettings, err error) { +func (s *Store) PinnedSettings(context.Context) (pinned pin.PinnedSettings, err error) { const query = `SELECT currency, threshold, storage_pinned, storage_price, ingress_pinned, ingress_price, egress_pinned, egress_price, max_collateral_pinned, max_collateral FROM host_pinned_settings;` @@ -28,7 +29,7 @@ FROM host_pinned_settings;` } // UpdatePinnedSettings updates the host's pinned settings. -func (s *Store) UpdatePinnedSettings(p pin.PinnedSettings) error { +func (s *Store) UpdatePinnedSettings(_ context.Context, p pin.PinnedSettings) error { const query = `INSERT INTO host_pinned_settings (id, currency, threshold, storage_pinned, storage_price, ingress_pinned, ingress_price, egress_pinned, egress_price, max_collateral_pinned, max_collateral) VALUES (0, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (id) DO UPDATE SET currency=EXCLUDED.currency, threshold=EXCLUDED.threshold, diff --git a/persist/sqlite/settings_test.go b/persist/sqlite/settings_test.go index e7d0f0ac..a5c1ad74 100644 --- a/persist/sqlite/settings_test.go +++ b/persist/sqlite/settings_test.go @@ -1,6 +1,7 @@ package sqlite import ( + "context" "encoding/hex" "errors" "math" @@ -60,9 +61,9 @@ func TestPinned(t *testing.T) { for i := 0; i < 10000; i++ { p := randomPinnedSettings() - if err := db.UpdatePinnedSettings(p); err != nil { + if err := db.UpdatePinnedSettings(context.Background(), p); err != nil { t.Fatal(err) - } else if p2, err := db.PinnedSettings(); err != nil { + } else if p2, err := db.PinnedSettings(context.Background()); err != nil { t.Fatal(err) } else if !reflect.DeepEqual(p, p2) { t.Fatalf("expected %v, got %v", p, p2) From 2201539d690aa8c29164fae1fb0f585120186dee Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Tue, 12 Mar 2024 07:33:23 -0700 Subject: [PATCH 19/20] pin: fix error context --- host/settings/pin/pin_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/host/settings/pin/pin_test.go b/host/settings/pin/pin_test.go index 883c8b25..6f62086d 100644 --- a/host/settings/pin/pin_test.go +++ b/host/settings/pin/pin_test.go @@ -46,7 +46,7 @@ func checkSettings(settings settings.Settings, pinned pin.PinnedSettings, expect if pinned.Storage.IsPinned() { storagePrice, err := pin.ConvertCurrencyToSC(decimal.NewFromFloat(pinned.Storage.Value), rate) if err != nil { - return fmt.Errorf("failed to convert storage price: %v", err) + return fmt.Errorf("failed to convert storage price: %w", err) } else if !storagePrice.Div64(4320).Div64(1e12).Equals(settings.StoragePrice) { return fmt.Errorf("expected storage price %d, got %d", storagePrice, settings.StoragePrice) } @@ -55,7 +55,7 @@ func checkSettings(settings settings.Settings, pinned pin.PinnedSettings, expect if pinned.Ingress.IsPinned() { ingressPrice, err := pin.ConvertCurrencyToSC(decimal.NewFromFloat(pinned.Ingress.Value), rate) if err != nil { - return fmt.Errorf("failed to convert storage price: %v", err) + return fmt.Errorf("failed to convert ingress price: %w", err) } else if !ingressPrice.Div64(1e12).Equals(settings.IngressPrice) { return fmt.Errorf("expected ingress price %d, got %d", ingressPrice, settings.IngressPrice) } @@ -64,7 +64,7 @@ func checkSettings(settings settings.Settings, pinned pin.PinnedSettings, expect if pinned.Egress.IsPinned() { egressPrice, err := pin.ConvertCurrencyToSC(decimal.NewFromFloat(pinned.Egress.Value), rate) if err != nil { - return fmt.Errorf("failed to convert storage price: %v", err) + return fmt.Errorf("failed to convert egress price: %w", err) } else if !egressPrice.Div64(1e12).Equals(settings.EgressPrice) { return fmt.Errorf("expected egress price %d, got %d", egressPrice, settings.EgressPrice) } @@ -73,7 +73,7 @@ func checkSettings(settings settings.Settings, pinned pin.PinnedSettings, expect if pinned.MaxCollateral.IsPinned() { maxCollateral, err := pin.ConvertCurrencyToSC(decimal.NewFromFloat(pinned.MaxCollateral.Value), rate) if err != nil { - return fmt.Errorf("failed to convert storage price: %v", err) + return fmt.Errorf("failed to convert max collateral: %w", err) } else if !maxCollateral.Equals(settings.MaxCollateral) { return fmt.Errorf("expected max collateral %d, got %d", maxCollateral, settings.MaxCollateral) } From eaf0726cbfda472863f676ecbff6643e8994813f Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Tue, 12 Mar 2024 09:28:06 -0700 Subject: [PATCH 20/20] pin,sqlite: fix lint errors --- host/settings/pin/pin.go | 1 + persist/sqlite/settings_test.go | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/host/settings/pin/pin.go b/host/settings/pin/pin.go index 1e9ad9d6..94f56375 100644 --- a/host/settings/pin/pin.go +++ b/host/settings/pin/pin.go @@ -80,6 +80,7 @@ type ( } ) +// IsPinned returns true if the pin is enabled and the value is greater than 0. func (p Pin) IsPinned() bool { return p.Pinned && p.Value > 0 } diff --git a/persist/sqlite/settings_test.go b/persist/sqlite/settings_test.go index a5c1ad74..d8ec5d5e 100644 --- a/persist/sqlite/settings_test.go +++ b/persist/sqlite/settings_test.go @@ -55,7 +55,6 @@ func TestPinned(t *testing.T) { db, err := OpenDatabase(filepath.Join(t.TempDir(), "hostdb.db"), log.Named("sqlite")) if err != nil { t.Fatal(err) - } defer db.Close()