diff --git a/api/api.go b/api/api.go index 45a6a02..2e078b2 100644 --- a/api/api.go +++ b/api/api.go @@ -4,6 +4,7 @@ import ( "encoding/json" "time" + "go.sia.tech/core/consensus" "go.sia.tech/core/types" "go.sia.tech/walletd/wallet" ) @@ -102,3 +103,23 @@ type RescanResponse struct { StartTime time.Time `json:"startTime"` Error *string `json:"error,omitempty"` } + +// An ApplyUpdate is a consensus update that was applied to the best chain. +type ApplyUpdate struct { + Update consensus.ApplyUpdate `json:"update"` + State consensus.State `json:"state"` + Block types.Block `json:"block"` +} + +// A RevertUpdate is a consensus update that was reverted from the best chain. +type RevertUpdate struct { + Update consensus.RevertUpdate `json:"update"` + State consensus.State `json:"state"` + Block types.Block `json:"block"` +} + +// ConsensusUpdatesResponse is the response type for /consensus/updates/:index. +type ConsensusUpdatesResponse struct { + Applied []ApplyUpdate `json:"applied"` + Reverted []RevertUpdate `json:"reverted"` +} diff --git a/api/api_test.go b/api/api_test.go index 5f0a077..c77434a 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -15,6 +15,7 @@ import ( "go.sia.tech/core/consensus" "go.sia.tech/core/gateway" "go.sia.tech/core/types" + "go.sia.tech/coreutils" "go.sia.tech/coreutils/chain" "go.sia.tech/coreutils/syncer" "go.sia.tech/jape" @@ -1214,3 +1215,69 @@ func TestP2P(t *testing.T) { t.Fatal(err) } } + +func TestConsensusUpdates(t *testing.T) { + log := zaptest.NewLogger(t) + + n, genesisBlock := testNetwork() + giftPrivateKey := types.GeneratePrivateKey() + giftAddress := types.StandardUnlockHash(giftPrivateKey.PublicKey()) + genesisBlock.Transactions[0].SiacoinOutputs[0] = types.SiacoinOutput{ + Value: types.Siacoins(1), + Address: giftAddress, + } + + // create wallets + dbstore, tipState, err := chain.NewDBStore(chain.NewMemDB(), n, genesisBlock) + if err != nil { + t.Fatal(err) + } + cm := chain.NewManager(dbstore, tipState) + + ws, err := sqlite.OpenDatabase(filepath.Join(t.TempDir(), "wallets.db"), log.Named("sqlite3")) + if err != nil { + t.Fatal(err) + } + defer ws.Close() + + wm, err := wallet.NewManager(cm, ws, wallet.WithLogger(log.Named("wallet"))) + if err != nil { + t.Fatal(err) + } + defer wm.Close() + + c, shutdown := runServer(cm, nil, wm) + defer shutdown() + + for i := 0; i < 10; i++ { + b, ok := coreutils.MineBlock(cm, types.VoidAddress, time.Second) + if !ok { + t.Fatal("failed to mine block") + } else if err := cm.AddBlocks([]types.Block{b}); err != nil { + t.Fatal(err) + } + } + + waitForBlock(t, cm, ws) + + reverted, applied, err := c.ConsensusUpdates(types.ChainIndex{}, 10) + if err != nil { + t.Fatal(err) + } else if len(reverted) != 0 { + t.Fatal("expected no reverted blocks") + } else if len(applied) != 11 { // genesis + 10 mined blocks (chain manager off-by-one) + t.Fatalf("expected 11 applied blocks, got %v", len(applied)) + } + + for i, cau := range applied { + // using i for height since we're testing the update contents + expected, ok := cm.BestIndex(uint64(i)) + if !ok { + t.Fatalf("failed to get expected index for block %v", i) + } else if cau.State.Index != expected { + t.Fatalf("expected index %v, got %v", expected, cau.State.Index) + } else if cau.State.Network.Name != n.Name { // TODO: better comparison. reflect.DeepEqual is failing in CI, but passing local. + t.Fatalf("expected network to be %q, got %q", n.Name, cau.State.Network.Name) + } + } +} diff --git a/api/client.go b/api/client.go index 6000dc2..3cd1685 100644 --- a/api/client.go +++ b/api/client.go @@ -2,10 +2,12 @@ package api import ( "fmt" + "sync" "time" "go.sia.tech/core/consensus" "go.sia.tech/core/types" + "go.sia.tech/coreutils/chain" "go.sia.tech/jape" "go.sia.tech/walletd/wallet" ) @@ -13,7 +15,23 @@ import ( // A Client provides methods for interacting with a walletd API server. type Client struct { c jape.Client - n *consensus.Network // for ConsensusTipState + + mu sync.Mutex // protects n + n *consensus.Network +} + +func (c *Client) getNetwork() (*consensus.Network, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.n == nil { + var err error + c.n, err = c.ConsensusNetwork() + if err != nil { + return nil, err + } + } + return c.n, nil } // State returns information about the current state of the walletd daemon. @@ -35,6 +53,13 @@ func (c *Client) TxpoolTransactions() (txns []types.Transaction, v2txns []types. return resp.Transactions, resp.V2Transactions, err } +// TxpoolParents returns the parents of a transaction that are currently in the +// transaction pool. +func (c *Client) TxpoolParents(txn types.Transaction) (resp []types.Transaction, err error) { + err = c.c.POST("/txpool/parents", txn, &resp) + return +} + // TxpoolFee returns the recommended fee (per weight unit) to ensure a high // probability of inclusion in the next block. func (c *Client) TxpoolFee() (resp types.Currency, err error) { @@ -49,22 +74,67 @@ func (c *Client) ConsensusNetwork() (resp *consensus.Network, err error) { return } -// ConsensusTip returns the current tip index. -func (c *Client) ConsensusTip() (resp types.ChainIndex, err error) { - err = c.c.GET("/consensus/tip", &resp) +// ConsensusIndex returns the consensus index at the specified height. +func (c *Client) ConsensusIndex(height uint64) (resp types.ChainIndex, err error) { + err = c.c.GET(fmt.Sprintf("/consensus/index/%d", height), &resp) return } +// ConsensusUpdates returns at most n consensus updates that have occurred since +// the specified index +func (c *Client) ConsensusUpdates(index types.ChainIndex, limit int) ([]chain.RevertUpdate, []chain.ApplyUpdate, error) { + // index.String() is a short-hand representation. We need the full text + indexBuf, err := index.MarshalText() + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal index: %w", err) + } + + var resp ConsensusUpdatesResponse + if err := c.c.GET(fmt.Sprintf("/consensus/updates/%s?limit=%d", indexBuf, limit), &resp); err != nil { + return nil, nil, err + } + + network, err := c.getNetwork() + if err != nil { + return nil, nil, fmt.Errorf("failed to get network metadata: %w", err) + } + + reverted := make([]chain.RevertUpdate, 0, len(resp.Reverted)) + for _, u := range resp.Reverted { + revert := chain.RevertUpdate{ + RevertUpdate: u.Update, + State: u.State, + Block: u.Block, + } + revert.State.Network = network + reverted = append(reverted, revert) + } + + applied := make([]chain.ApplyUpdate, 0, len(resp.Applied)) + for _, u := range resp.Applied { + apply := chain.ApplyUpdate{ + ApplyUpdate: u.Update, + State: u.State, + Block: u.Block, + } + apply.State.Network = network + applied = append(applied, apply) + } + return reverted, applied, nil +} + // ConsensusTipState returns the current tip state. func (c *Client) ConsensusTipState() (resp consensus.State, err error) { - if c.n == nil { - c.n, err = c.ConsensusNetwork() - if err != nil { - return - } + if err = c.c.GET("/consensus/tipstate", &resp); err != nil { + return } - err = c.c.GET("/consensus/tipstate", &resp) - resp.Network = c.n + resp.Network, err = c.getNetwork() + return +} + +// ConsensusTip returns the current tip index. +func (c *Client) ConsensusTip() (resp types.ChainIndex, err error) { + err = c.c.GET("/consensus/tip", &resp) return } diff --git a/api/server.go b/api/server.go index 0b236e4..e5f6677 100644 --- a/api/server.go +++ b/api/server.go @@ -15,6 +15,7 @@ import ( "go.sia.tech/core/consensus" "go.sia.tech/core/gateway" "go.sia.tech/core/types" + "go.sia.tech/coreutils/chain" "go.sia.tech/coreutils/syncer" "go.sia.tech/walletd/build" "go.sia.tech/walletd/wallet" @@ -23,6 +24,8 @@ import ( type ( // A ChainManager manages blockchain and txpool state. ChainManager interface { + UpdatesSince(types.ChainIndex, int) ([]chain.RevertUpdate, []chain.ApplyUpdate, error) + BestIndex(height uint64) (types.ChainIndex, bool) TipState() consensus.State AddBlocks([]types.Block) error @@ -120,6 +123,56 @@ func (s *server) consensusTipStateHandler(jc jape.Context) { jc.Encode(s.cm.TipState()) } +func (s *server) consensusIndexHeightHandler(jc jape.Context) { + var height uint64 + if jc.DecodeParam("height", &height) != nil { + return + } + index, ok := s.cm.BestIndex(height) + if !ok { + jc.Error(errors.New("height not found"), http.StatusNotFound) + return + } + jc.Encode(index) +} + +func (s *server) consensusUpdatesIndexHandler(jc jape.Context) { + var index types.ChainIndex + if jc.DecodeParam("index", &index) != nil { + return + } + + limit := 10 + if jc.DecodeForm("limit", &limit) != nil { + return + } else if limit <= 0 || limit > 100 { + jc.Error(errors.New("limit must be between 0 and 100"), http.StatusBadRequest) + return + } + + reverted, applied, err := s.cm.UpdatesSince(index, limit) + if jc.Check("couldn't get updates", err) != nil { + return + } + + var res ConsensusUpdatesResponse + for _, ru := range reverted { + res.Reverted = append(res.Reverted, RevertUpdate{ + Update: ru.RevertUpdate, + State: ru.State, + Block: ru.Block, + }) + } + for _, au := range applied { + res.Applied = append(res.Applied, ApplyUpdate{ + Update: au.ApplyUpdate, + State: au.State, + Block: au.Block, + }) + } + jc.Encode(res) +} + func (s *server) syncerPeersHandler(jc jape.Context) { var peers []GatewayPeer for _, p := range s.s.Peers() { @@ -173,6 +226,15 @@ func (s *server) syncerBroadcastBlockHandler(jc jape.Context) { } } +func (s *server) txpoolParentsHandler(jc jape.Context) { + var txn types.Transaction + if jc.Decode(&txn) != nil { + return + } + + jc.Encode(s.cm.UnconfirmedParents(txn)) +} + func (s *server) txpoolTransactionsHandler(jc jape.Context) { jc.Encode(TxpoolTransactionsResponse{ Transactions: s.cm.PoolTransactions(), @@ -765,14 +827,17 @@ func NewServer(cm ChainManager, s Syncer, wm WalletManager) http.Handler { return jape.Mux(map[string]jape.Handler{ "GET /state": srv.stateHandler, - "GET /consensus/network": srv.consensusNetworkHandler, - "GET /consensus/tip": srv.consensusTipHandler, - "GET /consensus/tipstate": srv.consensusTipStateHandler, + "GET /consensus/network": srv.consensusNetworkHandler, + "GET /consensus/tip": srv.consensusTipHandler, + "GET /consensus/tipstate": srv.consensusTipStateHandler, + "GET /consensus/updates/:index": srv.consensusUpdatesIndexHandler, + "GET /consensus/index/:height": srv.consensusIndexHeightHandler, "GET /syncer/peers": srv.syncerPeersHandler, "POST /syncer/connect": srv.syncerConnectHandler, "POST /syncer/broadcast/block": srv.syncerBroadcastBlockHandler, + "POST /txpool/parents": srv.txpoolParentsHandler, "GET /txpool/transactions": srv.txpoolTransactionsHandler, "GET /txpool/fee": srv.txpoolFeeHandler, "POST /txpool/broadcast": srv.txpoolBroadcastHandler,