diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index 02eef13f7..70b7859a9 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -55,6 +55,7 @@ Special thanks to external contributors on this release: - [pubsub] \#7319 Performance improvements for the event query API (@creachadair) - [node] \#7521 Define concrete type for seed node implementation (@spacech1mp) - [rpc] \#7612 paginate mempool /unconfirmed_txs rpc endpoint (@spacech1mp) +- [light] [\#7536](https://github.com/tendermint/tendermint/pull/7536) rpc /status call returns info about the light client (@jmalicevic) ### BUG FIXES diff --git a/internal/statesync/dispatcher.go b/internal/statesync/dispatcher.go index b12922343..9cdb34978 100644 --- a/internal/statesync/dispatcher.go +++ b/internal/statesync/dispatcher.go @@ -224,6 +224,9 @@ func (p *BlockProvider) ReportEvidence(ctx context.Context, ev types.Evidence) e // String implements stringer interface func (p *BlockProvider) String() string { return string(p.peer) } +// Returns the ID address of the provider (NodeID of peer) +func (p *BlockProvider) ID() string { return string(p.peer) } + //---------------------------------------------------------------- // peerList is a rolling list of peers. This is used to distribute the load of diff --git a/light/client.go b/light/client.go index 99a44f498..443b4d822 100644 --- a/light/client.go +++ b/light/client.go @@ -13,6 +13,7 @@ import ( tmmath "github.com/tendermint/tendermint/libs/math" "github.com/tendermint/tendermint/light/provider" "github.com/tendermint/tendermint/light/store" + "github.com/tendermint/tendermint/types" ) @@ -1146,3 +1147,29 @@ func (c *Client) providerShouldBeRemoved(err error) bool { errors.As(err, &provider.ErrBadLightBlock{}) || errors.Is(err, provider.ErrConnectionClosed) } + +func (c *Client) Status(ctx context.Context) *types.LightClientInfo { + chunks := make([]string, len(c.witnesses)) + + // If primary is in witness list we do not want to count it twice in the number of peers + primaryNotInWitnessList := 1 + for i, val := range c.witnesses { + chunks[i] = val.ID() + if chunks[i] == c.primary.ID() { + primaryNotInWitnessList = 0 + } + } + + return &types.LightClientInfo{ + PrimaryID: c.primary.ID(), + WitnessesID: chunks, + NumPeers: len(chunks) + primaryNotInWitnessList, + LastTrustedHeight: c.latestTrustedBlock.Height, + LastTrustedHash: c.latestTrustedBlock.Hash(), + LatestBlockTime: c.latestTrustedBlock.Time, + TrustingPeriod: c.trustingPeriod.String(), + // The caller of /status can deduce this from the two variables above + // Having a boolean flag improves readbility + TrustedBlockExpired: HeaderExpired(c.latestTrustedBlock.SignedHeader, c.trustingPeriod, time.Now()), + } +} diff --git a/light/client_benchmark_test.go b/light/client_benchmark_test.go index 59eb79766..ca0e402a0 100644 --- a/light/client_benchmark_test.go +++ b/light/client_benchmark_test.go @@ -61,6 +61,10 @@ func (impl *providerBenchmarkImpl) ReportEvidence(_ context.Context, _ types.Evi return errors.New("not implemented") } +// provierBenchmarkImpl does not have an ID iteself. +// Thus we return a sample string +func (impl *providerBenchmarkImpl) ID() string { return "ip-not-defined.com" } + func BenchmarkSequence(b *testing.B) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -168,4 +172,5 @@ func BenchmarkBackwards(b *testing.B) { b.Fatal(err) } } + } diff --git a/light/light_test.go b/light/light_test.go index 49a69ae47..00d0741ce 100644 --- a/light/light_test.go +++ b/light/light_test.go @@ -167,3 +167,93 @@ func waitForBlock(ctx context.Context, p provider.Provider, height int64) (*type } } } + +func TestClientStatusRPC(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + conf, err := rpctest.CreateConfig(t.Name()) + require.NoError(t, err) + + // Start a test application + app := kvstore.NewApplication() + + _, closer, err := rpctest.StartTendermint(ctx, conf, app, rpctest.SuppressStdout) + require.NoError(t, err) + defer func() { require.NoError(t, closer(ctx)) }() + + dbDir, err := os.MkdirTemp("", "light-client-test-status-example") + require.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(dbDir) }) + + chainID := conf.ChainID() + + primary, err := httpp.New(chainID, conf.RPC.ListenAddress) + require.NoError(t, err) + + // give Tendermint time to generate some blocks + block, err := waitForBlock(ctx, primary, 2) + require.NoError(t, err) + + db, err := dbm.NewGoLevelDB("light-client-db", dbDir) + require.NoError(t, err) + + // In order to not create a full testnet to verify whether we get the correct IPs + // if we have more than one witness, we add the primary multiple times + // TODO This should be buggy behavior, we should not be allowed to add the same nodes as witnesses + witnesses := []provider.Provider{primary, primary, primary} + + c, err := light.NewClient(ctx, + chainID, + light.TrustOptions{ + Period: 504 * time.Hour, // 21 days + Height: 2, + Hash: block.Hash(), + }, + primary, + witnesses, + dbs.New(db), + light.Logger(log.TestingLogger()), + ) + require.NoError(t, err) + + defer func() { require.NoError(t, c.Cleanup()) }() + + lightStatus := c.Status(ctx) + + // Verify primary IP + require.True(t, lightStatus.PrimaryID == primary.ID()) + + // Verify IPs of witnesses + require.ElementsMatch(t, mapProviderArrayToIP(witnesses), lightStatus.WitnessesID) + + // Verify that number of peers is equal to number of witnesses (+ 1 if the primary is not a witness) + require.Equal(t, len(witnesses)+1*primaryNotInWitnessList(witnesses, primary), lightStatus.NumPeers) + + // Verify that the last trusted hash returned matches the stored hash of the trusted + // block at the last trusted height. + blockAtTrustedHeight, err := c.TrustedLightBlock(lightStatus.LastTrustedHeight) + require.NoError(t, err) + + require.EqualValues(t, lightStatus.LastTrustedHash, blockAtTrustedHeight.Hash()) + +} + +// Extract the IP address of all the providers within an array +func mapProviderArrayToIP(el []provider.Provider) []string { + ips := make([]string, len(el)) + for i, v := range el { + ips[i] = v.ID() + } + return ips +} + +// If the primary is not in the witness list, we will return 1 +// Otherwise, return 0 +func primaryNotInWitnessList(witnesses []provider.Provider, primary provider.Provider) int { + for _, el := range witnesses { + if el == primary { + return 0 + } + } + return 1 +} diff --git a/light/provider/http/http.go b/light/provider/http/http.go index 79bb56c56..cf443e1b5 100644 --- a/light/provider/http/http.go +++ b/light/provider/http/http.go @@ -100,7 +100,8 @@ func NewWithClientAndOptions(chainID string, client rpcclient.RemoteClient, opti } } -func (p *http) String() string { +// Identifies the provider with an IP in string format +func (p *http) ID() string { return fmt.Sprintf("http{%s}", p.client.Remote()) } diff --git a/light/provider/http/http_test.go b/light/provider/http/http_test.go index 71cd78563..8749d4a11 100644 --- a/light/provider/http/http_test.go +++ b/light/provider/http/http_test.go @@ -3,7 +3,6 @@ package http_test import ( "context" "errors" - "fmt" "testing" "time" @@ -22,15 +21,15 @@ import ( func TestNewProvider(t *testing.T) { c, err := lighthttp.New("chain-test", "192.168.0.1:26657") require.NoError(t, err) - require.Equal(t, fmt.Sprintf("%s", c), "http{http://192.168.0.1:26657}") + require.Equal(t, c.ID(), "http{http://192.168.0.1:26657}") c, err = lighthttp.New("chain-test", "http://153.200.0.1:26657") require.NoError(t, err) - require.Equal(t, fmt.Sprintf("%s", c), "http{http://153.200.0.1:26657}") + require.Equal(t, c.ID(), "http{http://153.200.0.1:26657}") c, err = lighthttp.New("chain-test", "153.200.0.1") require.NoError(t, err) - require.Equal(t, fmt.Sprintf("%s", c), "http{http://153.200.0.1}") + require.Equal(t, c.ID(), "http{http://153.200.0.1}") } func TestProvider(t *testing.T) { diff --git a/light/provider/mocks/provider.go b/light/provider/mocks/provider.go index aa36fa2d3..1b4e583de 100644 --- a/light/provider/mocks/provider.go +++ b/light/provider/mocks/provider.go @@ -15,6 +15,20 @@ type Provider struct { mock.Mock } +// ID provides a mock function with given fields: +func (_m *Provider) ID() string { + ret := _m.Called() + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + // LightBlock provides a mock function with given fields: ctx, height func (_m *Provider) LightBlock(ctx context.Context, height int64) (*types.LightBlock, error) { ret := _m.Called(ctx, height) diff --git a/light/provider/provider.go b/light/provider/provider.go index 7f15d5c75..d1b3304da 100644 --- a/light/provider/provider.go +++ b/light/provider/provider.go @@ -25,4 +25,8 @@ type Provider interface { // ReportEvidence reports an evidence of misbehavior. ReportEvidence(context.Context, types.Evidence) error + + // Returns the ID of a provider. For RPC providers it returns the IP address of the client + // For p2p providers it returns a combination of NodeID and IP address + ID() string } diff --git a/light/rpc/client.go b/light/rpc/client.go index 272100422..3aab77c99 100644 --- a/light/rpc/client.go +++ b/light/rpc/client.go @@ -32,6 +32,7 @@ type LightClient interface { Update(ctx context.Context, now time.Time) (*types.LightBlock, error) VerifyLightBlockAtHeight(ctx context.Context, height int64, now time.Time) (*types.LightBlock, error) TrustedLightBlock(height int64) (*types.LightBlock, error) + Status(ctx context.Context) *types.LightClientInfo } var _ rpcclient.Client = (*Client)(nil) @@ -124,8 +125,18 @@ func (c *Client) OnStop() { } } +// Returns the status of the light client. Previously this was querying the primary connected to the client +// As a consequence of this change, running /status on the light client will return nil for SyncInfo, NodeInfo +// and ValdiatorInfo. func (c *Client) Status(ctx context.Context) (*coretypes.ResultStatus, error) { - return c.next.Status(ctx) + lightClientInfo := c.lc.Status(ctx) + + return &coretypes.ResultStatus{ + NodeInfo: types.NodeInfo{}, + SyncInfo: coretypes.SyncInfo{}, + ValidatorInfo: coretypes.ValidatorInfo{}, + LightClientInfo: *lightClientInfo, + }, nil } func (c *Client) ABCIInfo(ctx context.Context) (*coretypes.ResultABCIInfo, error) { diff --git a/light/rpc/mocks/light_client.go b/light/rpc/mocks/light_client.go index cc32cf649..347d14707 100644 --- a/light/rpc/mocks/light_client.go +++ b/light/rpc/mocks/light_client.go @@ -31,6 +31,22 @@ func (_m *LightClient) ChainID() string { return r0 } +// Status provides a mock function with given fields: ctx +func (_m *LightClient) Status(ctx context.Context) *types.LightClientInfo { + ret := _m.Called(ctx) + + var r0 *types.LightClientInfo + if rf, ok := ret.Get(0).(func(context.Context) *types.LightClientInfo); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.LightClientInfo) + } + } + + return r0 +} + // TrustedLightBlock provides a mock function with given fields: height func (_m *LightClient) TrustedLightBlock(height int64) (*types.LightBlock, error) { ret := _m.Called(height) diff --git a/rpc/coretypes/responses.go b/rpc/coretypes/responses.go index 223a25ff7..47649557c 100644 --- a/rpc/coretypes/responses.go +++ b/rpc/coretypes/responses.go @@ -124,9 +124,10 @@ type ValidatorInfo struct { // Node Status type ResultStatus struct { - NodeInfo types.NodeInfo `json:"node_info"` - SyncInfo SyncInfo `json:"sync_info"` - ValidatorInfo ValidatorInfo `json:"validator_info"` + NodeInfo types.NodeInfo `json:"node_info"` + SyncInfo SyncInfo `json:"sync_info"` + ValidatorInfo ValidatorInfo `json:"validator_info"` + LightClientInfo types.LightClientInfo `json:"light_client_info,omitempty"` } // Is TxIndexing enabled diff --git a/test/e2e/runner/rpc.go b/test/e2e/runner/rpc.go index f6a32b114..4ca8cc016 100644 --- a/test/e2e/runner/rpc.go +++ b/test/e2e/runner/rpc.go @@ -128,6 +128,8 @@ func waitForHeight(ctx context.Context, testnet *e2e.Testnet, height int64) (*ty // waitForNode waits for a node to become available and catch up to the given block height. func waitForNode(ctx context.Context, node *e2e.Node, height int64) (*rpctypes.ResultStatus, error) { + // If the node is the light client or seed note, we do not check for the last height. + // The light client and seed note can be behind the full node and validator if node.Mode == e2e.ModeSeed { return nil, nil } @@ -167,7 +169,10 @@ func waitForNode(ctx context.Context, node *e2e.Node, height int64) (*rpctypes.R return nil, fmt.Errorf("timed out waiting for %v to reach height %v", node.Name, height) case errors.Is(err, context.Canceled): return nil, err - case err == nil && status.SyncInfo.LatestBlockHeight >= height: + // If the node is the light client, it is not essential to wait for it to catch up, but we must return status info + case err == nil && node.Mode == e2e.ModeLight: + return status, nil + case err == nil && node.Mode != e2e.ModeLight && status.SyncInfo.LatestBlockHeight >= height: return status, nil case counter%500 == 0: switch { diff --git a/test/e2e/runner/start.go b/test/e2e/runner/start.go index 967d2519c..43ff2eef3 100644 --- a/test/e2e/runner/start.go +++ b/test/e2e/runner/start.go @@ -118,8 +118,17 @@ func Start(ctx context.Context, testnet *e2e.Testnet) error { wcancel() node.HasStarted = true + + var lastNodeHeight int64 + + // If the node is a light client, we fetch its current height + if node.Mode == e2e.ModeLight { + lastNodeHeight = status.LightClientInfo.LastTrustedHeight + } else { + lastNodeHeight = status.SyncInfo.LatestBlockHeight + } logger.Info(fmt.Sprintf("Node %v up on http://127.0.0.1:%v at height %v", - node.Name, node.ProxyPort, status.SyncInfo.LatestBlockHeight)) + node.Name, node.ProxyPort, lastNodeHeight)) } return nil diff --git a/types/light.go b/types/light.go index 5a650a159..3b1ddfcbe 100644 --- a/types/light.go +++ b/types/light.go @@ -4,10 +4,26 @@ import ( "bytes" "errors" "fmt" + "time" + tbytes "github.com/tendermint/tendermint/libs/bytes" tmproto "github.com/tendermint/tendermint/proto/tendermint/types" ) +// Info about the status of the light client +type LightClientInfo struct { + PrimaryID string `json:"primaryID"` + WitnessesID []string `json:"witnessesID"` + NumPeers int `json:"number_of_peers,string"` + LastTrustedHeight int64 `json:"last_trusted_height,string"` + LastTrustedHash tbytes.HexBytes `json:"last_trusted_hash"` + LatestBlockTime time.Time `json:"latest_block_time"` + TrustingPeriod string `json:"trusting_period"` + // Boolean that reflects whether LatestBlockTime + trusting period is before + // time.Now() (time when /status is called) + TrustedBlockExpired bool `json:"trusted_block_expired"` +} + // LightBlock is a SignedHeader and a ValidatorSet. // It is the basis of the light client type LightBlock struct {