Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ them atomically via an emulator-signed Arkade transaction.
```
arkd tx stream ─► Solver ─► Plugin.Match(tx) ─► intent ─► Plugin.Solve(intent)
└── fans every tx out to all registered plugins
└── runs enabled plugins in one runtime
```

A solver bot is a small runtime that subscribes to arkd's transaction stream and
Expand Down Expand Up @@ -41,8 +41,10 @@ two plugins ship with the daemon:
- **`pkg/preimage`** — preimage-gated claim solver. Decrypts an ECIES payload
attached to the funding tx and claims the VTXO when the arkade-script matches.

Each enabled plugin owns its own `Solver` and arkd subscription, so adding a
new protocol means writing a new `Plugin` and wiring it in `cmd/solverd`. See
`solverd` composes enabled plugins into one `Solver` runtime. The runtime may
still use per-plugin arkd subscriptions internally so server-side filters can
drop unrelated txs before they reach the bot. Adding a new protocol means
writing a new `Plugin` and wiring it in `cmd/solverd`. See
[`pkg/solver/README.md`](pkg/solver/README.md) for the plugin authoring guide.

## Packages
Expand Down Expand Up @@ -76,9 +78,8 @@ dispatches each one to its registered plugins.
- `Plugin` interface — `Match(ctx, *psbt.Packet) (intent any, ok bool)` decides
whether a tx is interesting; `Solve(ctx, intent)` reacts to a match.
- `Solver` / `New(plugins ...Plugin)` — runtime wrapping one or more plugins.
- `Run(ctx, <-chan *psbt.Packet) error` — drains the channel sequentially,
fans matches out to `Solve` goroutines. Returns `ctx.Err()` on cancel,
`nil` when the channel closes.
- `Run(ctx, source) error` — subscribes plugins, fans matches out to `Solve`
goroutines, and returns `ctx.Err()` on cancel.

### `pkg/banco`

Expand Down Expand Up @@ -143,8 +144,8 @@ web UI. Configured entirely through environment variables:
| `SOLVER_BANCO_ENABLED` | | `true` | enable the swap plugin |
| `SOLVER_PREIMAGE_ENABLED` | | `false` | enable the preimage-claim plugin |

At least one plugin must be enabled. Each enabled plugin owns its own solver
and arkd subscription.
At least one plugin must be enabled. The daemon registers all enabled plugins
in one solver runtime.

### `solver`

Expand Down
191 changes: 19 additions & 172 deletions cmd/solverd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,206 +2,53 @@ package main

import (
"context"
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"os"
"os/signal"
"syscall"

arkdclient "github.com/arkade-os/arkd/pkg/client-lib"
singlekey "github.com/arkade-os/arkd/pkg/client-lib/identity/singlekey"
singlekeyfilestore "github.com/arkade-os/arkd/pkg/client-lib/identity/singlekey/store/file"
emulatorclient "github.com/arkade-os/emulator/pkg/client"
arksdk "github.com/arkade-os/go-sdk"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"

"github.com/arkade-os/solver/internal/config"
"github.com/arkade-os/solver/internal/core/application"
sqlitedb "github.com/arkade-os/solver/internal/infrastructure/db/sqlite"
"github.com/arkade-os/solver/internal/infrastructure/pricefeed"
grpcservice "github.com/arkade-os/solver/internal/interface/grpc"
"github.com/arkade-os/solver/pkg/banco"
"github.com/arkade-os/solver/pkg/solver"
"github.com/arkade-os/solver/internal/solverd"
)

// Version is injected at build time via -ldflags "-X main.Version=<tag>".
// Defaults to "dev" for local builds.
var Version = "dev"

func main() {
cfg, err := config.LoadConfig()
if err != nil {
logrus.WithError(err).Fatal("failed to load config")
}

log := logrus.New()
log.SetLevel(logrus.Level(cfg.LogLevel))

if err := os.MkdirAll(cfg.Datadir, 0750); err != nil {
log.WithError(err).Fatal("failed to create datadir")
if err := run(log); err != nil {
log.Error(err)
os.Exit(1)
}
}

emulatorConn, err := grpc.NewClient(
cfg.EmulatorURL,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.WithError(err).Fatal("failed to connect to emulator")
}
// nolint:errcheck
defer emulatorConn.Close()
emulator := emulatorclient.NewGRPCClient(emulatorConn)

ctx := context.Background()
identityStore, err := singlekeyfilestore.NewStore(cfg.Datadir)
if err != nil {
log.WithError(err).Fatal("failed to init identity store")
}
singleKeyIdentity, err := singlekey.NewIdentity(identityStore)
if err != nil {
log.WithError(err).Fatal("failed to init single-key identity")
}
walletOpts := []arksdk.WalletOption{arksdk.WithIdentity(singleKeyIdentity)}
arkClient, err := arksdk.LoadWallet(cfg.Datadir, walletOpts...)
func run(log *logrus.Logger) error {
cfg, err := config.LoadConfig()
if err != nil {
// Fresh datadir surfaces as either go-sdk's or client-lib's
// ErrNotInitialized depending on which layer first noticed the
// missing config — both are valid "no wallet yet" signals.
if !errors.Is(err, arksdk.ErrNotInitialized) &&
!errors.Is(err, arkdclient.ErrNotInitialized) {
log.WithError(err).Fatal("failed to load ark client")
}
// Fresh datadir — create and initialize the wallet.
arkClient, err = arksdk.NewWallet(cfg.Datadir, walletOpts...)
if err != nil {
log.WithError(err).Fatal("failed to create ark client")
}
if err := arkClient.Init(ctx, cfg.ArkURL, cfg.WalletSeed, cfg.WalletPassword); err != nil {
log.WithError(err).Fatal("failed to init ark client")
}
}
if err := arkClient.Unlock(ctx, cfg.WalletPassword); err != nil {
log.WithError(err).Fatal("failed to unlock ark client")
}
defer arkClient.Stop()

var (
takerSvc *application.TakerService
preimageSvc *application.PreimageService
srv *grpcservice.Server
db = optionalSqliteDB(cfg, log)
)
if db != nil {
// nolint:errcheck
defer db.Close()
}

if cfg.BancoEnabled {
if db == nil {
log.Fatal("banco plugin requires sqlite datadir")
}
pairRepo := sqlitedb.NewPairRepository(db)
tradeRepo := sqlitedb.NewTradeRepository(db)
priceFeed := pricefeed.NewCoinGecko()
tradeListener := application.NewTradeListener(tradeRepo, log)

plugin := banco.NewPlugin(banco.Config{
SolverClient: arkClient,
Emulator: emulator,
PairsRepository: pairRepo,
PriceFeed: priceFeed,
Listener: tradeListener,
Log: log,
})
s := solver.New(plugin).WithLogger(log)

takerSvc = application.NewTakerService(s, pairRepo, tradeRepo, arkClient, arkClient.Indexer(), log)
takerSvc.Start()
log.Info("banco plugin started")
return err
}

if cfg.PreimageEnabled {
solverPriv, err := deriveSolverPrivKey(cfg.WalletSeed)
if err != nil {
log.WithError(err).Fatal("failed to derive preimage solver privkey")
}
preimageSvc, err = application.NewPreimageService(ctx, application.PreimageServiceConfig{
ArkClient: arkClient,
Emulator: emulator,
SolverPrivKey: solverPriv,
Log: log,
})
if err != nil {
log.WithError(err).Fatal("failed to create preimage service")
}
if err := preimageSvc.Start(); err != nil {
log.WithError(err).Fatal("failed to start preimage service")
}
log.Info("preimage plugin started")
}

// One gRPC + HTTP server hosts whichever services are enabled.
if cfg.BancoEnabled || cfg.PreimageEnabled {
srv = grpcservice.NewServer(takerSvc, cfg.GRPCPort, cfg.HTTPPort, log).
WithPreimageService(preimageSvc)
if err := srv.Start(); err != nil {
log.WithError(err).Fatal("failed to start server")
}
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

log.WithField("version", Version).
WithField("banco", cfg.BancoEnabled).
WithField("preimage", cfg.PreimageEnabled).
Info("solverd started")

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh

log.Info("shutting down...")
if preimageSvc != nil {
preimageSvc.Stop()
}
if takerSvc != nil {
takerSvc.Stop()
}
if srv != nil {
srv.Stop()
}
log.Info("solverd stopped")
}
Info("starting solverd")

// optionalSqliteDB opens the sqlite DB iff banco plugin is enabled
func optionalSqliteDB(cfg *config.Config, log logrus.FieldLogger) *sql.DB {
if !cfg.BancoEnabled {
return nil
}
db, err := sqlitedb.OpenDB(cfg.Datadir)
wallet, err := solverd.SetupWallet(ctx, cfg)
if err != nil {
log.WithError(err).Fatal("failed to open database")
return err
}
return db
}
defer wallet.Stop()

const preimageKeyDomain = "solverd/preimage-plugin/v1"

// deriveSolverPrivKey derives the preimage-plugin's encryption privkey from
// the wallet seed via HMAC-SHA256(seed, domain). Stable across restarts as
// long as the seed is unchanged.
func deriveSolverPrivKey(seedHex string) (*btcec.PrivateKey, error) {
seed, err := hex.DecodeString(seedHex)
if err != nil {
return nil, fmt.Errorf("decode wallet seed: %w", err)
if err := solverd.Run(ctx, cfg, log, wallet); err != nil {
return err
}
mac := hmac.New(sha256.New, seed)
mac.Write([]byte(preimageKeyDomain))
priv, _ := btcec.PrivKeyFromBytes(mac.Sum(nil))
return priv, nil
log.Info("solverd stopped")

return nil
}
Loading
Loading