Files
ark/server/internal/app-config/config.go
Dusan Sekulic ae3ccb3579 Add support for announcing market hours (#380)
* feat: add market hour configuration for optimal payment timing

This commit adds market hour configuration to help users determine optimal
times for making payments with lower fees. The configuration is managed
through environment variables and exposed via the GetInfo RPC.

Changes:
- Add MarketHour message type to protobuf service definition
- Add market hour configuration fields to Config struct
- Update covenant and covenantless services to handle market hour data
- Extend GetInfo RPC response to include market hour information
- Set default market hour period to 24 hours
- Initialize market hour fields after other service fields

Configuration:
- ARK_FIRST_MARKET_HOUR: Initial market hour timestamp
  (default: current server start time)
- ARK_MARKET_HOUR_PERIOD: Time between market hours in seconds
  (default: 86400)
- ARK_MARKET_HOUR_ROUND_LIFETIME: Round lifetime for market hours
  (default: 0, falls back to ARK_ROUND_LIFETIME)

* feat: add admin RPC for updating market hour configuration

Add new UpdateMarketHour RPC to AdminService for configuring market hour parameters:
- Add request/response messages to admin.proto
- Add UpdateMarketHour method to Service interface
- Implement market hour updates in covenant and covenantless services
- Add validation for market hour parameters
- Implement admin gRPC handler

The RPC allows updating:
- First market hour timestamp
- Market hour period
- Market hour round lifetime (optional, defaults to round lifetime

* feat: add market hour persistence with sqlite

- Add MarketHourRepo interface in domain layer
- Implement market hour persistence using SQLite
- Add market hour queries to sqlc/query.sql
- Update service initialization to load market hours from DB
- Add fallback to config values if no DB entry exists
- Update RepoManager interface with new MarketHourRepo method
2024-11-22 10:36:51 +01:00

431 lines
11 KiB
Go

package appconfig
import (
"fmt"
"strings"
"time"
"github.com/ark-network/ark/common"
"github.com/ark-network/ark/server/internal/core/application"
"github.com/ark-network/ark/server/internal/core/ports"
"github.com/ark-network/ark/server/internal/infrastructure/db"
blockscheduler "github.com/ark-network/ark/server/internal/infrastructure/scheduler/block"
timescheduler "github.com/ark-network/ark/server/internal/infrastructure/scheduler/gocron"
txbuilder "github.com/ark-network/ark/server/internal/infrastructure/tx-builder/covenant"
cltxbuilder "github.com/ark-network/ark/server/internal/infrastructure/tx-builder/covenantless"
envunlocker "github.com/ark-network/ark/server/internal/infrastructure/unlocker/env"
fileunlocker "github.com/ark-network/ark/server/internal/infrastructure/unlocker/file"
btcwallet "github.com/ark-network/ark/server/internal/infrastructure/wallet/btc-embedded"
liquidwallet "github.com/ark-network/ark/server/internal/infrastructure/wallet/liquid-standalone"
"github.com/nbd-wtf/go-nostr"
log "github.com/sirupsen/logrus"
)
const minAllowedSequence = 512
var (
supportedEventDbs = supportedType{
"badger": {},
}
supportedDbs = supportedType{
"badger": {},
"sqlite": {},
}
supportedSchedulers = supportedType{
"gocron": {},
"block": {},
}
supportedTxBuilders = supportedType{
"covenant": {},
"covenantless": {},
}
supportedUnlockers = supportedType{
"env": {},
"file": {},
}
supportedNetworks = supportedType{
common.Bitcoin.Name: {},
common.BitcoinTestNet.Name: {},
common.BitcoinRegTest.Name: {},
common.BitcoinSigNet.Name: {},
common.Liquid.Name: {},
common.LiquidTestNet.Name: {},
common.LiquidRegTest.Name: {},
}
)
type Config struct {
DbType string
EventDbType string
DbDir string
DbMigrationPath string
EventDbDir string
RoundInterval int64
Network common.Network
SchedulerType string
TxBuilderType string
WalletAddr string
RoundLifetime int64
UnilateralExitDelay int64
BoardingExitDelay int64
NostrDefaultRelays []string
NoteUriPrefix string
MarketHourStartTime time.Time
MarketHourEndTime time.Time
MarketHourPeriod time.Duration
MarketHourRoundInterval time.Duration
EsploraURL string
NeutrinoPeer string
BitcoindRpcUser string
BitcoindRpcPass string
BitcoindRpcHost string
BitcoindZMQBlock string
BitcoindZMQTx string
UnlockerType string
UnlockerFilePath string // file unlocker
UnlockerPassword string // env unlocker
repo ports.RepoManager
svc application.Service
adminSvc application.AdminService
wallet ports.WalletService
txBuilder ports.TxBuilder
scanner ports.BlockchainScanner
scheduler ports.SchedulerService
unlocker ports.Unlocker
}
func (c *Config) Validate() error {
if !supportedEventDbs.supports(c.EventDbType) {
return fmt.Errorf("event db type not supported, please select one of: %s", supportedEventDbs)
}
if !supportedDbs.supports(c.DbType) {
return fmt.Errorf("db type not supported, please select one of: %s", supportedDbs)
}
if !supportedSchedulers.supports(c.SchedulerType) {
return fmt.Errorf("scheduler type not supported, please select one of: %s", supportedSchedulers)
}
if !supportedTxBuilders.supports(c.TxBuilderType) {
return fmt.Errorf("tx builder type not supported, please select one of: %s", supportedTxBuilders)
}
if len(c.UnlockerType) > 0 && !supportedUnlockers.supports(c.UnlockerType) {
return fmt.Errorf("unlocker type not supported, please select one of: %s", supportedUnlockers)
}
if c.RoundInterval < 2 {
return fmt.Errorf("invalid round interval, must be at least 2 seconds")
}
if !supportedNetworks.supports(c.Network.Name) {
return fmt.Errorf("invalid network, must be one of: %s", supportedNetworks)
}
if c.RoundLifetime < minAllowedSequence {
if c.SchedulerType != "block" {
return fmt.Errorf("scheduler type must be block if round lifetime is expressed in blocks")
}
} else {
if c.SchedulerType != "gocron" {
return fmt.Errorf("scheduler type must be gocron if round lifetime is expressed in seconds")
}
// round life time must be a multiple of 512 if expressed in seconds
if c.RoundLifetime%minAllowedSequence != 0 {
c.RoundLifetime -= c.RoundLifetime % minAllowedSequence
log.Infof(
"round lifetime must be a multiple of %d, rounded to %d",
minAllowedSequence, c.RoundLifetime,
)
}
}
if c.UnilateralExitDelay < minAllowedSequence {
return fmt.Errorf(
"invalid unilateral exit delay, must at least %d", minAllowedSequence,
)
}
if c.BoardingExitDelay < minAllowedSequence {
return fmt.Errorf(
"invalid boarding exit delay, must at least %d", minAllowedSequence,
)
}
if c.UnilateralExitDelay%minAllowedSequence != 0 {
c.UnilateralExitDelay -= c.UnilateralExitDelay % minAllowedSequence
log.Infof(
"unilateral exit delay must be a multiple of %d, rounded to %d",
minAllowedSequence, c.UnilateralExitDelay,
)
}
if c.BoardingExitDelay%minAllowedSequence != 0 {
c.BoardingExitDelay -= c.BoardingExitDelay % minAllowedSequence
log.Infof(
"boarding exit delay must be a multiple of %d, rounded to %d",
minAllowedSequence, c.BoardingExitDelay,
)
}
if len(c.NostrDefaultRelays) == 0 {
return fmt.Errorf("missing nostr default relays")
}
for _, relay := range c.NostrDefaultRelays {
if !nostr.IsValidRelayURL(relay) {
return fmt.Errorf("invalid nostr relay url: %s", relay)
}
}
if err := c.repoManager(); err != nil {
return err
}
if err := c.walletService(); err != nil {
return err
}
if err := c.txBuilderService(); err != nil {
return err
}
if err := c.scannerService(); err != nil {
return err
}
if err := c.schedulerService(); err != nil {
return err
}
if err := c.adminService(); err != nil {
return err
}
if err := c.unlockerService(); err != nil {
return err
}
return nil
}
func (c *Config) AppService() (application.Service, error) {
if c.svc == nil {
if err := c.appService(); err != nil {
return nil, err
}
}
return c.svc, nil
}
func (c *Config) AdminService() application.AdminService {
return c.adminSvc
}
func (c *Config) WalletService() ports.WalletService {
return c.wallet
}
func (c *Config) UnlockerService() ports.Unlocker {
return c.unlocker
}
func (c *Config) repoManager() error {
var svc ports.RepoManager
var err error
var eventStoreConfig []interface{}
var dataStoreConfig []interface{}
logger := log.New()
switch c.EventDbType {
case "badger":
eventStoreConfig = []interface{}{c.EventDbDir, logger}
default:
return fmt.Errorf("unknown event db type")
}
switch c.DbType {
case "badger":
dataStoreConfig = []interface{}{c.DbDir, logger}
case "sqlite":
dataStoreConfig = []interface{}{c.DbDir, c.DbMigrationPath}
default:
return fmt.Errorf("unknown db type")
}
svc, err = db.NewService(db.ServiceConfig{
EventStoreType: c.EventDbType,
DataStoreType: c.DbType,
EventStoreConfig: eventStoreConfig,
DataStoreConfig: dataStoreConfig,
})
if err != nil {
return err
}
c.repo = svc
return nil
}
func (c *Config) walletService() error {
if common.IsLiquid(c.Network) {
svc, err := liquidwallet.NewService(c.WalletAddr)
if err != nil {
return fmt.Errorf("failed to connect to wallet: %s", err)
}
c.wallet = svc
return nil
}
// Check if both Neutrino peer and Bitcoind RPC credentials are provided
if c.NeutrinoPeer != "" && (c.BitcoindRpcUser != "" || c.BitcoindRpcPass != "") {
return fmt.Errorf("cannot use both Neutrino peer and Bitcoind RPC credentials")
}
var svc ports.WalletService
var err error
switch {
case c.BitcoindZMQBlock != "" && c.BitcoindZMQTx != "" && c.BitcoindRpcUser != "" && c.BitcoindRpcPass != "":
svc, err = btcwallet.NewService(btcwallet.WalletConfig{
Datadir: c.DbDir,
Network: c.Network,
}, btcwallet.WithBitcoindZMQ(c.BitcoindZMQBlock, c.BitcoindZMQTx, c.BitcoindRpcHost, c.BitcoindRpcUser, c.BitcoindRpcPass))
case c.BitcoindRpcUser != "" && c.BitcoindRpcPass != "":
svc, err = btcwallet.NewService(btcwallet.WalletConfig{
Datadir: c.DbDir,
Network: c.Network,
}, btcwallet.WithPollingBitcoind(c.BitcoindRpcHost, c.BitcoindRpcUser, c.BitcoindRpcPass))
default:
// Default to Neutrino for Bitcoin mainnet or when NeutrinoPeer is explicitly set
if len(c.EsploraURL) == 0 {
return fmt.Errorf("missing esplora url, covenant-less ark requires ARK_ESPLORA_URL to be set")
}
svc, err = btcwallet.NewService(btcwallet.WalletConfig{
Datadir: c.DbDir,
Network: c.Network,
}, btcwallet.WithNeutrino(c.NeutrinoPeer, c.EsploraURL))
}
if err != nil {
return err
}
c.wallet = svc
return nil
}
func (c *Config) txBuilderService() error {
var svc ports.TxBuilder
var err error
switch c.TxBuilderType {
case "covenant":
svc = txbuilder.NewTxBuilder(
c.wallet, c.Network, c.RoundLifetime, c.BoardingExitDelay,
)
case "covenantless":
svc = cltxbuilder.NewTxBuilder(
c.wallet, c.Network, c.RoundLifetime, c.BoardingExitDelay,
)
default:
err = fmt.Errorf("unknown tx builder type")
}
if err != nil {
return err
}
c.txBuilder = svc
return nil
}
func (c *Config) scannerService() error {
c.scanner = c.wallet
return nil
}
func (c *Config) schedulerService() error {
var svc ports.SchedulerService
var err error
switch c.SchedulerType {
case "gocron":
svc = timescheduler.NewScheduler()
case "block":
svc, err = blockscheduler.NewScheduler(c.EsploraURL)
default:
err = fmt.Errorf("unknown scheduler type")
}
if err != nil {
return err
}
c.scheduler = svc
return nil
}
func (c *Config) appService() error {
if common.IsLiquid(c.Network) {
svc, err := application.NewCovenantService(
c.Network, c.RoundInterval, c.RoundLifetime, c.UnilateralExitDelay, c.BoardingExitDelay, c.NostrDefaultRelays,
c.wallet, c.repo, c.txBuilder, c.scanner, c.scheduler, c.NoteUriPrefix,
c.MarketHourStartTime, c.MarketHourEndTime, c.MarketHourPeriod, c.MarketHourRoundInterval,
)
if err != nil {
return err
}
c.svc = svc
return nil
}
svc, err := application.NewCovenantlessService(
c.Network, c.RoundInterval, c.RoundLifetime, c.UnilateralExitDelay, c.BoardingExitDelay, c.NostrDefaultRelays,
c.wallet, c.repo, c.txBuilder, c.scanner, c.scheduler, c.NoteUriPrefix,
c.MarketHourStartTime, c.MarketHourEndTime, c.MarketHourPeriod, c.MarketHourRoundInterval,
)
if err != nil {
return err
}
c.svc = svc
return nil
}
func (c *Config) adminService() error {
unit := ports.UnixTime
if c.RoundLifetime < minAllowedSequence {
unit = ports.BlockHeight
}
c.adminSvc = application.NewAdminService(c.wallet, c.repo, c.txBuilder, unit)
return nil
}
func (c *Config) unlockerService() error {
if len(c.UnlockerType) <= 0 {
return nil
}
var svc ports.Unlocker
var err error
switch c.UnlockerType {
case "file":
svc, err = fileunlocker.NewService(c.UnlockerFilePath)
case "env":
svc, err = envunlocker.NewService(c.UnlockerPassword)
default:
err = fmt.Errorf("unknown unlocker type")
}
if err != nil {
return err
}
c.unlocker = svc
return nil
}
type supportedType map[string]struct{}
func (t supportedType) String() string {
types := make([]string, 0, len(t))
for tt := range t {
types = append(types, tt)
}
return strings.Join(types, " | ")
}
func (t supportedType) supports(typeStr string) bool {
_, ok := t[typeStr]
return ok
}