diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index af724e8a..cabf5dfb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -5,6 +5,8 @@ on: push: branches: - master +env: + CGO_ENABLED: 1 jobs: test: @@ -14,7 +16,7 @@ jobs: strategy: matrix: os: [ ubuntu-latest , macos-latest, windows-latest ] - go-version: [ '1.19', '1.20' ] + go-version: [ '1.20', '1.21' ] steps: - name: Configure git run: git config --global core.autocrlf false # required on Windows diff --git a/api/client.go b/api/client.go index ac97d73d..e76ee7f5 100644 --- a/api/client.go +++ b/api/client.go @@ -1,6 +1,8 @@ package api import ( + "fmt" + "go.sia.tech/core/consensus" "go.sia.tech/core/types" "go.sia.tech/jape" @@ -58,3 +60,21 @@ func (c *Client) SyncerBroadcastBlock(b types.Block) (err error) { err = c.c.POST("/syncer/broadcast/block", b, nil) return } + +// Tip returns the current tip of the explorer. +func (c *Client) Tip() (resp types.ChainIndex, err error) { + err = c.c.GET("/explorer/tip", &resp) + return +} + +// Block returns the block with the specified ID. +func (c *Client) Block(id types.BlockID) (resp types.Block, err error) { + err = c.c.GET(fmt.Sprintf("/explorer/block/id/%s", id), &resp) + return +} + +// BlockHeight returns the block with the specified height. +func (c *Client) BlockHeight(height uint64) (resp types.Block, err error) { + err = c.c.GET(fmt.Sprintf("/explorer/block/height/%d", height), &resp) + return +} diff --git a/api/server.go b/api/server.go index a3be7682..a1fd030b 100644 --- a/api/server.go +++ b/api/server.go @@ -36,10 +36,18 @@ type ( BroadcastV2TransactionSet(txns []types.V2Transaction) BroadcastV2BlockOutline(bo gateway.V2BlockOutline) } + + // Explorer implements a Sia explorer. + Explorer interface { + Tip() (types.ChainIndex, error) + BlockByID(id types.BlockID) (types.Block, error) + BlockByHeight(height uint64) (types.Block, error) + } ) type server struct { cm ChainManager + e Explorer s Syncer mu sync.Mutex @@ -124,10 +132,43 @@ func (s *server) txpoolBroadcastHandler(jc jape.Context) { } } +func (s *server) explorerTipHandler(jc jape.Context) { + tip, err := s.e.Tip() + if jc.Check("failed to get tip", err) != nil { + return + } + jc.Encode(tip) +} + +func (s *server) explorerBlockHandler(jc jape.Context) { + var id types.BlockID + if jc.DecodeParam("id", &id) != nil { + return + } + block, err := s.e.BlockByID(id) + if jc.Check("failed to get block", err) != nil { + return + } + jc.Encode(block) +} + +func (s *server) explorerBlockHeightHandler(jc jape.Context) { + var height uint64 + if jc.DecodeParam("height", &height) != nil { + return + } + block, err := s.e.BlockByHeight(height) + if jc.Check("failed to get block", err) != nil { + return + } + jc.Encode(block) +} + // NewServer returns an HTTP handler that serves the explored API. -func NewServer(cm ChainManager, s Syncer) http.Handler { +func NewServer(e Explorer, cm ChainManager, s Syncer) http.Handler { srv := server{ cm: cm, + e: e, s: s, } return jape.Mux(map[string]jape.Handler{ @@ -138,5 +179,9 @@ func NewServer(cm ChainManager, s Syncer) http.Handler { "GET /txpool/transactions": srv.txpoolTransactionsHandler, "GET /txpool/fee": srv.txpoolFeeHandler, "POST /txpool/broadcast": srv.txpoolBroadcastHandler, + + "GET /explorer/tip": srv.explorerTipHandler, + "GET /explorer/block/id/:id": srv.explorerBlockHandler, + "GET /explorer/block/height/:height": srv.explorerBlockHeightHandler, }) } diff --git a/cmd/explored/node.go b/cmd/explored/node.go index 3890958c..81ca9eb1 100644 --- a/cmd/explored/node.go +++ b/cmd/explored/node.go @@ -156,11 +156,21 @@ func newNode(addr, dir string, chainNetwork string, useUPNP bool, logger *zap.Lo } cm := chain.NewManager(dbstore, tipState) - store, err := sqlite.OpenDatabase("./explore.db", logger) + store, err := sqlite.OpenDatabase(filepath.Join(dir, "./explore.db"), logger) if err != nil { - panic(err) + return nil, err } e := explorer.NewExplorer(store) + tip, err := store.Tip() + if errors.Is(err, sqlite.ErrNoTip) { + tip = types.ChainIndex{ + ID: genesisBlock.ID(), + Height: 0, + } + } else if err != nil { + return nil, err + } + cm.AddSubscriber(store, tip) l, err := net.Listen("tcp", addr) if err != nil { diff --git a/cmd/explored/web.go b/cmd/explored/web.go index fbaab8e4..c4bc646e 100644 --- a/cmd/explored/web.go +++ b/cmd/explored/web.go @@ -10,7 +10,7 @@ import ( ) func startWeb(l net.Listener, node *node, password string) error { - renter := api.NewServer(node.cm, node.s) + renter := api.NewServer(node.e, node.cm, node.s) api := jape.BasicAuth(password)(renter) return http.Serve(l, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api") { diff --git a/explorer/explorer.go b/explorer/explorer.go index 64af30b1..4865cd20 100644 --- a/explorer/explorer.go +++ b/explorer/explorer.go @@ -1,10 +1,19 @@ package explorer -import "go.sia.tech/core/chain" +import ( + "go.sia.tech/core/chain" + "go.sia.tech/core/types" +) // A Store is a database that stores information about elements, contracts, // and blocks. -type Store interface{} +type Store interface { + chain.Subscriber + + Tip() (types.ChainIndex, error) + BlockByID(id types.BlockID) (types.Block, error) + BlockByHeight(height uint64) (types.Block, error) +} // Explorer implements a Sia explorer. type Explorer struct { @@ -13,15 +22,20 @@ type Explorer struct { // NewExplorer returns a Sia explorer. func NewExplorer(s Store) *Explorer { - return &Explorer{s} + return &Explorer{s: s} +} + +// Tip returns the tip of the best known valid chain. +func (e *Explorer) Tip() (types.ChainIndex, error) { + return e.s.Tip() } -// ProcessChainApplyUpdate implements chain.Subscriber. -func (e *Explorer) ProcessChainApplyUpdate(cau *chain.ApplyUpdate, mayCommit bool) error { - return nil +// BlockByID returns the block with the specified ID. +func (e *Explorer) BlockByID(id types.BlockID) (types.Block, error) { + return e.s.BlockByID(id) } -// ProcessChainRevertUpdate implements chain.Subscriber. -func (e *Explorer) ProcessChainRevertUpdate(cru *chain.RevertUpdate) error { - return nil +// BlockByHeight returns the block with the specified height. +func (e *Explorer) BlockByHeight(height uint64) (types.Block, error) { + return e.s.BlockByHeight(height) } diff --git a/go.mod b/go.mod index e5829bdb..01cca0dc 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,8 @@ require ( github.com/mattn/go-sqlite3 v1.14.19 go.etcd.io/bbolt v1.3.7 go.sia.tech/core v0.1.12-0.20231021194448-f1e65eb9f0d0 - go.sia.tech/jape v0.9.0 + go.sia.tech/jape v0.11.1 + go.uber.org/zap v1.26.0 golang.org/x/term v0.6.0 lukechampine.com/frand v1.4.2 lukechampine.com/upnp v0.3.0 @@ -17,7 +18,6 @@ require ( github.com/julienschmidt/httprouter v1.3.0 // indirect go.sia.tech/mux v1.2.0 // indirect go.uber.org/multierr v1.10.0 // indirect - go.uber.org/zap v1.26.0 // indirect golang.org/x/crypto v0.0.0-20220507011949-2cf3adece122 // indirect golang.org/x/sys v0.6.0 // indirect golang.org/x/tools v0.7.0 // indirect diff --git a/go.sum b/go.sum index d513a318..5ef5bcbb 100644 --- a/go.sum +++ b/go.sum @@ -13,8 +13,11 @@ go.sia.tech/core v0.1.12-0.20231021194448-f1e65eb9f0d0 h1:2nKOKa99g9h9m3hL5UortA go.sia.tech/core v0.1.12-0.20231021194448-f1e65eb9f0d0/go.mod h1:3EoY+rR78w1/uGoXXVqcYdwSjSJKuEMI5bL7WROA27Q= go.sia.tech/jape v0.9.0 h1:kWgMFqALYhLMJYOwWBgJda5ko/fi4iZzRxHRP7pp8NY= go.sia.tech/jape v0.9.0/go.mod h1:4QqmBB+t3W7cNplXPj++ZqpoUb2PeiS66RLpXmEGap4= +go.sia.tech/jape v0.11.1 h1:M7IP+byXL7xOqzxcHUQuXW+q3sYMkYzmMlMw+q8ZZw0= +go.sia.tech/jape v0.11.1/go.mod h1:4QqmBB+t3W7cNplXPj++ZqpoUb2PeiS66RLpXmEGap4= go.sia.tech/mux v1.2.0 h1:ofa1Us9mdymBbGMY2XH/lSpY8itFsKIo/Aq8zwe+GHU= go.sia.tech/mux v1.2.0/go.mod h1:Yyo6wZelOYTyvrHmJZ6aQfRoer3o4xyKQ4NmQLJrBSo= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= diff --git a/persist/sqlite/init.sql b/persist/sqlite/init.sql index b80d5b7d..50043329 100644 --- a/persist/sqlite/init.sql +++ b/persist/sqlite/init.sql @@ -3,5 +3,23 @@ CREATE TABLE global_settings ( db_version INTEGER NOT NULL -- used for migrations ); +CREATE TABLE blocks ( + id BLOB NOT NULL PRIMARY KEY, + height INTEGER NOT NULL, + parent_id BLOB NOT NULL, + nonce BLOB NOT NULL, + timestamp INTEGER NOT NULL +); + +CREATE TABLE miner_payouts ( + block_id BLOB REFERENCES blocks(id) ON DELETE CASCADE NOT NULL, + block_order INTEGER NOT NULL, + address BLOB NOT NULL, + value BLOB NOT NULL, + UNIQUE(block_id, block_order) +); + +CREATE INDEX miner_payouts_index ON miner_payouts(block_id); + -- initialize the global settings table INSERT INTO global_settings (id, db_version) VALUES (0, 0); -- should not be changed diff --git a/persist/sqlite/query.go b/persist/sqlite/query.go new file mode 100644 index 00000000..91e7bc07 --- /dev/null +++ b/persist/sqlite/query.go @@ -0,0 +1,104 @@ +package sqlite + +import ( + "database/sql" + "errors" + "time" + + "go.sia.tech/core/types" +) + +var ( + // ErrNoTip is returned when Tip() is unable to find any blocks in the + // database and thus there is no tip. It does not mean there was an + // error in the underlying database. + ErrNoTip = errors.New("no tip found") +) + +func decode(obj types.DecoderFrom, data []byte) error { + d := types.NewBufDecoder(data) + obj.DecodeFrom(d) + return d.Err() +} + +func decodeUint64(x *uint64, data []byte) error { + d := types.NewBufDecoder(data) + if x != nil { + *x = d.ReadUint64() + } + return d.Err() +} + +// Tip implements explorer.Store. +func (s *Store) Tip() (result types.ChainIndex, err error) { + var data []byte + err = s.queryRow("SELECT id, height FROM blocks WHERE height = (SELECT MAX(height) from blocks)").Scan(&data, &result.Height) + if errors.Is(err, sql.ErrNoRows) { + err = ErrNoTip + return + } else if err != nil { + return + } + if err = decode(&result.ID, data); err != nil { + return + } + return +} + +// BlockByID implements explorer.Store. +func (s *Store) BlockByID(id types.BlockID) (result types.Block, err error) { + { + var timestamp int64 + var parentID, nonce []byte + if err = s.queryRow("SELECT parent_id, nonce, timestamp FROM blocks WHERE id = ?", encode(id)).Scan(&parentID, &nonce, ×tamp); err != nil { + return + } + result.Timestamp = time.Unix(timestamp, 0).UTC() + if err = decode(&result.ParentID, parentID); err != nil { + return + } + if err = decodeUint64(&result.Nonce, nonce); err != nil { + return + } + } + + { + var rows *loggedRows + if rows, err = s.query("SELECT address, value FROM miner_payouts WHERE block_id = ? ORDER BY block_order", encode(id)); err != nil { + return + } + defer rows.Close() + + var address, value []byte + for rows.Next() { + if err = rows.Scan(&address, &value); err != nil { + return + } + var minerPayout types.SiacoinOutput + if err = decode(&minerPayout.Address, address); err != nil { + return + } + if err = decode(&minerPayout.Value, value); err != nil { + return + } + result.MinerPayouts = append(result.MinerPayouts, minerPayout) + } + } + + return +} + +// BlockByHeight implements explorer.Store. +func (s *Store) BlockByHeight(height uint64) (result types.Block, err error) { + var data []byte + if err = s.queryRow("SELECT id FROM blocks WHERE height = ?", height).Scan(&data); err != nil { + return + } + + var bid types.BlockID + if err = decode(&bid, data); err != nil { + return + } + result, err = s.BlockByID(bid) + return +} diff --git a/persist/sqlite/store.go b/persist/sqlite/store.go index 458b2cc2..a9671e14 100644 --- a/persist/sqlite/store.go +++ b/persist/sqlite/store.go @@ -7,9 +7,11 @@ import ( "fmt" "math" "strings" + "sync" "time" "github.com/mattn/go-sqlite3" + "go.sia.tech/core/chain" "go.uber.org/zap" "lukechampine.com/frand" ) @@ -19,6 +21,9 @@ type ( Store struct { db *sql.DB log *zap.Logger + + mu sync.Mutex + pendingUpdates []*chain.ApplyUpdate } ) diff --git a/persist/sqlite/txn.go b/persist/sqlite/txn.go new file mode 100644 index 00000000..ccb0a630 --- /dev/null +++ b/persist/sqlite/txn.go @@ -0,0 +1,92 @@ +package sqlite + +import ( + "bytes" + "fmt" + + "go.sia.tech/core/chain" + "go.sia.tech/core/types" +) + +func encode(obj types.EncoderTo) []byte { + var buf bytes.Buffer + e := types.NewEncoder(&buf) + obj.EncodeTo(e) + e.Flush() + return buf.Bytes() +} + +func encodeUint64(x uint64) []byte { + var buf bytes.Buffer + e := types.NewEncoder(&buf) + e.WriteUint64(x) + e.Flush() + return buf.Bytes() +} + +func (s *Store) addBlock(tx txn, b types.Block, height uint64) error { + // nonce is encoded because database/sql doesn't support uint64 with high bit set + _, err := tx.Exec("INSERT INTO blocks(id, height, parent_id, nonce, timestamp) VALUES (?, ?, ?, ?, ?);", encode(b.ID()), height, encode(b.ParentID), encodeUint64(b.Nonce), b.Timestamp.Unix()) + return err +} + +func (s *Store) addMinerPayouts(tx txn, bid types.BlockID, scos []types.SiacoinOutput) error { + for i, sco := range scos { + if _, err := tx.Exec("INSERT INTO miner_payouts(block_id, block_order, address, value) VALUES (?, ?, ?, ?);", encode(bid), i, encode(sco.Address), encode(sco.Value)); err != nil { + return err + } + } + return nil +} + +func (s *Store) deleteBlock(tx txn, bid types.BlockID) error { + _, err := tx.Exec("DELETE FROM blocks WHERE id = ?", encode(bid)) + return err +} + +func (s *Store) applyUpdates() error { + return s.transaction(func(tx txn) error { + for _, update := range s.pendingUpdates { + if err := s.addBlock(tx, update.Block, update.State.Index.Height); err != nil { + return fmt.Errorf("applyUpdates: failed to add block: %v", err) + } else if err := s.addMinerPayouts(tx, update.Block.ID(), update.Block.MinerPayouts); err != nil { + return fmt.Errorf("applyUpdates: failed to add miner payouts: %v", err) + } + } + s.pendingUpdates = s.pendingUpdates[:0] + return nil + }) +} + +func (s *Store) revertUpdate(cru *chain.RevertUpdate) error { + return s.transaction(func(tx txn) error { + if err := s.deleteBlock(tx, cru.Block.ID()); err != nil { + return fmt.Errorf("revertUpdate: failed to delete block: %v", err) + } + return nil + }) +} + +// ProcessChainApplyUpdate implements chain.Subscriber. +func (s *Store) ProcessChainApplyUpdate(cau *chain.ApplyUpdate, mayCommit bool) error { + s.mu.Lock() + defer s.mu.Unlock() + + s.pendingUpdates = append(s.pendingUpdates, cau) + if mayCommit { + return s.applyUpdates() + } + return nil +} + +// ProcessChainRevertUpdate implements chain.Subscriber. +func (s *Store) ProcessChainRevertUpdate(cru *chain.RevertUpdate) error { + s.mu.Lock() + defer s.mu.Unlock() + + if len(s.pendingUpdates) > 0 && s.pendingUpdates[len(s.pendingUpdates)-1].Block.ID() == cru.Block.ID() { + s.pendingUpdates = s.pendingUpdates[:len(s.pendingUpdates)-1] + return nil + } + return s.revertUpdate(cru) +}