From 9a04dfee2b51963b7619c31d35be357dca31aa84 Mon Sep 17 00:00:00 2001 From: Jernej Kos Date: Wed, 1 Jul 2020 14:15:24 +0200 Subject: [PATCH] go/consensus: Expose read-only state via light client interface Nodes configured as consensus RPC services workers now expose read-only access to consensus state via the usual MKVS ReadSyncer interface, allowing light clients to remotely query state while transparently verifying proofs. --- .changelog/3077.feature.md | 5 ++ go/consensus/api/api.go | 5 ++ go/consensus/api/grpc.go | 124 ++++++++++++++++++++++++++ go/consensus/api/light.go | 10 ++- go/consensus/tendermint/abci/mux.go | 6 +- go/consensus/tendermint/api/api.go | 18 +++- go/consensus/tendermint/full.go | 6 ++ go/consensus/tendermint/tendermint.go | 5 +- go/consensus/tests/tester.go | 38 ++++++++ 9 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 .changelog/3077.feature.md diff --git a/.changelog/3077.feature.md b/.changelog/3077.feature.md new file mode 100644 index 00000000000..7b88086948c --- /dev/null +++ b/.changelog/3077.feature.md @@ -0,0 +1,5 @@ +go/consensus: Expose read-only state via light client interface + +Nodes configured as consensus RPC services workers now expose read-only +access to consensus state via the usual MKVS ReadSyncer interface, allowing +light clients to remotely query state while transparently verifying proofs. diff --git a/go/consensus/api/api.go b/go/consensus/api/api.go index c43c0cc0694..a926436f836 100644 --- a/go/consensus/api/api.go +++ b/go/consensus/api/api.go @@ -20,6 +20,7 @@ import ( roothash "github.com/oasisprotocol/oasis-core/go/roothash/api" scheduler "github.com/oasisprotocol/oasis-core/go/scheduler/api" staking "github.com/oasisprotocol/oasis-core/go/staking/api" + mkvsNode "github.com/oasisprotocol/oasis-core/go/storage/mkvs/node" ) const ( @@ -99,6 +100,8 @@ type Block struct { Hash []byte `json:"hash"` // Time is the second-granular consensus time. Time time.Time `json:"time"` + // StateRoot is the Merkle root of the consensus state tree. + StateRoot mkvsNode.Root `json:"state_root"` // Meta contains the consensus backend specific block metadata. Meta cbor.RawMessage `json:"meta"` } @@ -119,6 +122,8 @@ type Status struct { LatestHash []byte `json:"latest_hash"` // LatestTime is the timestamp of the latest block. LatestTime time.Time `json:"latest_time"` + // LatestStateRoot is the Merkle root of the consensus state tree. + LatestStateRoot mkvsNode.Root `json:"latest_state_root"` // GenesisHeight is the height of the genesis block. GenesisHeight int64 `json:"genesis_height"` diff --git a/go/consensus/api/grpc.go b/go/consensus/api/grpc.go index 7b35bc116a5..909abf5e784 100644 --- a/go/consensus/api/grpc.go +++ b/go/consensus/api/grpc.go @@ -10,6 +10,7 @@ import ( "github.com/oasisprotocol/oasis-core/go/consensus/api/transaction" epochtime "github.com/oasisprotocol/oasis-core/go/epochtime/api" genesis "github.com/oasisprotocol/oasis-core/go/genesis/api" + "github.com/oasisprotocol/oasis-core/go/storage/mkvs/syncer" ) var ( @@ -48,6 +49,12 @@ var ( methodGetValidatorSet = lightServiceName.NewMethod("GetValidatorSet", int64(0)) // methodGetParameters is the GetParameters method. methodGetParameters = lightServiceName.NewMethod("GetParameters", int64(0)) + // methodStateSyncGet is the StateSyncGet method. + methodStateSyncGet = lightServiceName.NewMethod("StateSyncGet", syncer.GetRequest{}) + // methodStateSyncGetPrefixes is the StateSyncGetPrefixes method. + methodStateSyncGetPrefixes = lightServiceName.NewMethod("StateSyncGetPrefixes", syncer.GetPrefixesRequest{}) + // methodStateSyncIterate is the StateSyncIterate method. + methodStateSyncIterate = lightServiceName.NewMethod("StateSyncIterate", syncer.IterateRequest{}) // serviceDesc is the gRPC service descriptor. serviceDesc = grpc.ServiceDesc{ @@ -121,6 +128,18 @@ var ( MethodName: methodGetParameters.ShortName(), Handler: handlerGetParameters, }, + { + MethodName: methodStateSyncGet.ShortName(), + Handler: handlerStateSyncGet, + }, + { + MethodName: methodStateSyncGetPrefixes.ShortName(), + Handler: handlerStateSyncGetPrefixes, + }, + { + MethodName: methodStateSyncIterate.ShortName(), + Handler: handlerStateSyncIterate, + }, }, } ) @@ -444,6 +463,75 @@ func handlerGetParameters( // nolint: golint return interceptor(ctx, height, info, handler) } +func handlerStateSyncGet( // nolint: golint + srv interface{}, + ctx context.Context, + dec func(interface{}) error, + interceptor grpc.UnaryServerInterceptor, +) (interface{}, error) { + rq := new(syncer.GetRequest) + if err := dec(rq); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(LightClientBackend).State().SyncGet(ctx, rq) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: methodStateSyncGet.FullName(), + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(LightClientBackend).State().SyncGet(ctx, req.(*syncer.GetRequest)) + } + return interceptor(ctx, rq, info, handler) +} + +func handlerStateSyncGetPrefixes( // nolint: golint + srv interface{}, + ctx context.Context, + dec func(interface{}) error, + interceptor grpc.UnaryServerInterceptor, +) (interface{}, error) { + rq := new(syncer.GetPrefixesRequest) + if err := dec(rq); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(LightClientBackend).State().SyncGetPrefixes(ctx, rq) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: methodStateSyncGetPrefixes.FullName(), + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(LightClientBackend).State().SyncGetPrefixes(ctx, req.(*syncer.GetPrefixesRequest)) + } + return interceptor(ctx, rq, info, handler) +} + +func handlerStateSyncIterate( // nolint: golint + srv interface{}, + ctx context.Context, + dec func(interface{}) error, + interceptor grpc.UnaryServerInterceptor, +) (interface{}, error) { + rq := new(syncer.IterateRequest) + if err := dec(rq); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(LightClientBackend).State().SyncIterate(ctx, rq) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: methodStateSyncIterate.FullName(), + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(LightClientBackend).State().SyncIterate(ctx, req.(*syncer.IterateRequest)) + } + return interceptor(ctx, rq, info, handler) +} + // RegisterService registers a new client backend service with the given gRPC server. func RegisterService(server *grpc.Server, service ClientBackend) { server.RegisterService(&serviceDesc, service) @@ -486,6 +574,42 @@ func (c *consensusLightClient) GetParameters(ctx context.Context, height int64) return &rsp, nil } +type stateReadSync struct { + c *consensusLightClient +} + +// Implements syncer.ReadSyncer. +func (rs *stateReadSync) SyncGet(ctx context.Context, request *syncer.GetRequest) (*syncer.ProofResponse, error) { + var rsp syncer.ProofResponse + if err := rs.c.conn.Invoke(ctx, methodStateSyncGet.FullName(), request, &rsp); err != nil { + return nil, err + } + return &rsp, nil +} + +// Implements syncer.ReadSyncer. +func (rs *stateReadSync) SyncGetPrefixes(ctx context.Context, request *syncer.GetPrefixesRequest) (*syncer.ProofResponse, error) { + var rsp syncer.ProofResponse + if err := rs.c.conn.Invoke(ctx, methodStateSyncGetPrefixes.FullName(), request, &rsp); err != nil { + return nil, err + } + return &rsp, nil +} + +// Implements syncer.ReadSyncer. +func (rs *stateReadSync) SyncIterate(ctx context.Context, request *syncer.IterateRequest) (*syncer.ProofResponse, error) { + var rsp syncer.ProofResponse + if err := rs.c.conn.Invoke(ctx, methodStateSyncIterate.FullName(), request, &rsp); err != nil { + return nil, err + } + return &rsp, nil +} + +// Implements LightClientBackend. +func (c *consensusLightClient) State() syncer.ReadSyncer { + return &stateReadSync{c} +} + type consensusClient struct { consensusLightClient diff --git a/go/consensus/api/light.go b/go/consensus/api/light.go index bef57e18177..a9533e5642e 100644 --- a/go/consensus/api/light.go +++ b/go/consensus/api/light.go @@ -1,6 +1,10 @@ package api -import "context" +import ( + "context" + + "github.com/oasisprotocol/oasis-core/go/storage/mkvs/syncer" +) // LightClientBackend is the limited consensus interface used by light clients. type LightClientBackend interface { @@ -13,6 +17,10 @@ type LightClientBackend interface { // GetParameters returns the consensus parameters for a specific height. GetParameters(ctx context.Context, height int64) (*Parameters, error) + // State returns a MKVS read syncer that can be used to read consensus state from a remote node + // and verify it against the trusted local root. + State() syncer.ReadSyncer + // TODO: Move SubmitEvidence etc. from Backend. } diff --git a/go/consensus/tendermint/abci/mux.go b/go/consensus/tendermint/abci/mux.go index fb6d4c4b784..405d8800620 100644 --- a/go/consensus/tendermint/abci/mux.go +++ b/go/consensus/tendermint/abci/mux.go @@ -263,9 +263,9 @@ func (a *ApplicationServer) EstimateGas(caller signature.PublicKey, tx *transact return a.mux.EstimateGas(caller, tx) } -// BlockHeight returns the last committed block height. -func (a *ApplicationServer) BlockHeight() int64 { - return a.mux.state.BlockHeight() +// State returns the application state. +func (a *ApplicationServer) State() api.ApplicationQueryState { + return a.mux.state } // NewApplicationServer returns a new ApplicationServer, using the provided diff --git a/go/consensus/tendermint/api/api.go b/go/consensus/tendermint/api/api.go index 3a003f7fe54..86c22a6f7d3 100644 --- a/go/consensus/tendermint/api/api.go +++ b/go/consensus/tendermint/api/api.go @@ -18,6 +18,7 @@ import ( "github.com/oasisprotocol/oasis-core/go/common/node" consensus "github.com/oasisprotocol/oasis-core/go/consensus/api" "github.com/oasisprotocol/oasis-core/go/consensus/tendermint/crypto" + mkvsNode "github.com/oasisprotocol/oasis-core/go/storage/mkvs/node" ) // BackendName is the consensus backend name. @@ -175,10 +176,25 @@ func NewBlock(blk *tmtypes.Block) *consensus.Block { } rawMeta := cbor.Marshal(meta) + var stateRoot hash.Hash + switch blk.Header.AppHash { + case nil: + stateRoot.Empty() + default: + if err := stateRoot.UnmarshalBinary(blk.Header.AppHash); err != nil { + // This should NEVER happen. + panic(err) + } + } + return &consensus.Block{ Height: blk.Header.Height, Hash: blk.Header.Hash(), Time: blk.Header.Time, - Meta: rawMeta, + StateRoot: mkvsNode.Root{ + Version: uint64(blk.Header.Height) - 1, + Hash: stateRoot, + }, + Meta: rawMeta, } } diff --git a/go/consensus/tendermint/full.go b/go/consensus/tendermint/full.go index 83dc569d752..cc61611dbd8 100644 --- a/go/consensus/tendermint/full.go +++ b/go/consensus/tendermint/full.go @@ -9,6 +9,7 @@ import ( tmstate "github.com/tendermint/tendermint/state" consensusAPI "github.com/oasisprotocol/oasis-core/go/consensus/api" + "github.com/oasisprotocol/oasis-core/go/storage/mkvs/syncer" ) // We must use Tendermint's amino codec as some Tendermint's types are not easily unmarshallable. @@ -73,3 +74,8 @@ func (t *tendermintService) GetParameters(ctx context.Context, height int64) (*c Meta: aminoCodec.MustMarshalBinaryBare(params.ConsensusParams), }, nil } + +// Implements LightClientBackend. +func (t *tendermintService) State() syncer.ReadSyncer { + return t.mux.State().Storage() +} diff --git a/go/consensus/tendermint/tendermint.go b/go/consensus/tendermint/tendermint.go index d669d7076ac..7adbd129f37 100644 --- a/go/consensus/tendermint/tendermint.go +++ b/go/consensus/tendermint/tendermint.go @@ -746,6 +746,7 @@ func (t *tendermintService) GetStatus(ctx context.Context) (*consensusAPI.Status status.LatestHeight = latestBlk.Height status.LatestHash = latestBlk.Hash status.LatestTime = latestBlk.Time + status.LatestStateRoot = latestBlk.StateRoot case consensusAPI.ErrNoCommittedBlocks: // No committed blocks yet. default: @@ -904,7 +905,7 @@ func (t *tendermintService) GetTendermintBlock(ctx context.Context, height int64 // Do not let Tendermint determine the latest height (e.g., by passing nil here) as that // completely ignores ABCI processing so it can return a block for which local state does // not yet exist. Use our mux notion of latest height instead. - tmHeight = t.mux.BlockHeight() + tmHeight = t.mux.State().BlockHeight() if tmHeight == 0 { // No committed blocks yet. return nil, nil @@ -939,7 +940,7 @@ func (t *tendermintService) GetBlockResults(height int64) (*tmrpctypes.ResultBlo // from our mux. var tmHeight int64 if height == consensusAPI.HeightLatest { - tmHeight = t.mux.BlockHeight() + tmHeight = t.mux.State().BlockHeight() if tmHeight == 0 { // No committed blocks yet. return nil, consensusAPI.ErrNoCommittedBlocks diff --git a/go/consensus/tests/tester.go b/go/consensus/tests/tester.go index 04ced6afaad..8c87072d1a5 100644 --- a/go/consensus/tests/tester.go +++ b/go/consensus/tests/tester.go @@ -14,6 +14,7 @@ import ( "github.com/oasisprotocol/oasis-core/go/consensus/api/transaction" "github.com/oasisprotocol/oasis-core/go/consensus/tendermint/apps/epochtime_mock" staking "github.com/oasisprotocol/oasis-core/go/staking/api" + "github.com/oasisprotocol/oasis-core/go/storage/mkvs" ) const ( @@ -43,8 +44,13 @@ func ConsensusImplementationTests(t *testing.T, backend consensus.ClientBackend) require.NoError(err, "GetStatus") require.NotNil(status, "returned status should not be nil") require.EqualValues(1, status.GenesisHeight, "genesis height must be 1") + + blk, err = backend.GetBlock(ctx, status.LatestHeight) + require.NoError(err, "GetBlock") + require.EqualValues(blk.Height, status.LatestHeight, "latest block heights should match") require.EqualValues(blk.Hash, status.LatestHash, "latest block hashes should match") + require.EqualValues(blk.StateRoot, status.LatestStateRoot, "latest state roots should match") _, err = backend.GetTransactions(ctx, consensus.HeightLatest) require.NoError(err, "GetTransactions") @@ -99,4 +105,36 @@ func ConsensusImplementationTests(t *testing.T, backend consensus.ClientBackend) require.NoError(err, "GetParameters") require.Equal(params.Height, blk.Height, "returned parameters height should be correct") require.NotNil(params.Meta, "returned parameters should contain metadata") + + // We should be able to do remote state queries. Of course the state format is backend-specific + // so we simply perform some usual storage operations like fetching random keys and iterating + // through everything. + state := mkvs.NewWithRoot(backend.State(), nil, blk.StateRoot) + defer state.Close() + + it := state.NewIterator(ctx) + defer it.Close() + + var keys [][]byte + for it.Rewind(); it.Valid(); it.Next() { + keys = append(keys, it.Key()) + } + require.NoError(it.Err(), "iterator should not return an error") + require.NotEmpty(keys, "there should be some keys in consensus state") + + // Start with a clean tree to avoid hitting the cache. + state = mkvs.NewWithRoot(backend.State(), nil, blk.StateRoot) + defer state.Close() + + for _, key := range keys { + _, err = state.Get(ctx, key) + require.NoError(err, "state.Get(%X)", key) + } + + // Start with a clean tree to avoid hitting the cache. + state = mkvs.NewWithRoot(backend.State(), nil, blk.StateRoot) + defer state.Close() + + err = state.PrefetchPrefixes(ctx, keys[:1], 10) + require.NoError(err, "state.PrefetchPrefixes") }