diff --git a/README.md b/README.md index 9d7f75f..aeedc9f 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,12 @@ to [dev.poktroll.com/category/actors](https://dev.poktroll.com/category/actors). - [Get Gateway Delegating Applications](#get-gateway-delegating-applications) - [Send Relay](#send-relay) - [Helper functions](#helper-functions) -- [ShannonSDK Internals](#shannonsdk-internals) +- [ShannonSDK Internals \& Design (for developers only)](#shannonsdk-internals--design-for-developers-only) + - [Code Organization](#code-organization) + - [Interface Design](#interface-design) + - [Exposed Concrete Types](#exposed-concrete-types) + - [sdk.go](#sdkgo) + - [Public vs Private Fields](#public-vs-private-fields) - [Implementation Details](#implementation-details) - [Error Handling](#error-handling) - [Dependencies implementation](#dependencies-implementation) @@ -188,7 +193,110 @@ poktHTTPRequest, requestBz, err := sdktypes.SerializeHTTPRequest(request) serviceResponse, err := sdktypes.DeserializeHTTPResponse(relayResponse.Payload) ``` -## ShannonSDK Internals +## ShannonSDK Internals & Design (for developers only) + +### Code Organization + +The following is the top-level structure the SDK repo is moving towards: + +```bash +application.go +block.go +relay.go +session.go +sign.go +``` + +_TODO_DOCUMENT: Add the output of `tree -L 2` once the above structure is implemented._ +_TODO_DOCUMENT: Add a mermaid diagram of the exposed types once complete._ + +#### Interface Design + +The `SDK` **IS NOT DESIGNED** to provide interfaces to the consumer. + +The `SDK` **IS DESIGNED** to consume functionality from other packages via interfaces. + +This follows Golang's best practices for interfaces as described [here](https://go.dev/wiki/CodeReviewComments#interfaces). + +As a concrete example, the `Client` struct is exported directly from the `net/http` package + +```go +type Client struct { + // Transport specifies the mechanism by which individual + // HTTP requests are made. + // If nil, DefaultTransport is used. + Transport RoundTripper +``` + +Note that the above `Client` struct has multiple public methods, yet no code exists to force it to fulfill a specific interface. + +As a concrete example of keeping interfaces on the consumer side, the above `Client` consumes a `RoundTripper` interface. +As the godoc page for `net/http` specifies: `For control over proxies, TLS configuration, keep-alives, compression, and other settings, create a Transport`: + +```go +tr := &http.Transport{ + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + DisableCompression: true, +} +client := &http.Client{Transport: tr} +``` + +Example of a helper function used for overriding the default `RoundTripper`: + +```func NewFileTransport(fs FileSystem) RoundTripper``` + +#### Exposed Concrete Types + +Each file (in the top level directory) will have a client implemented and returned +as a concrete struct. + +For example, `ApplicationClient` is a `struct` that will be returned by `application.go` +rather than an interface. + +#### sdk.go + +**NOTE: If you are reading this and the documentation is outdated, please update it!** + +The `sdk.go` is a **TEMPORARY** that needs to be split file needs to be split into +`application.go`, `supplier.go`, etc... + +A `ShannonSDK` struct was defined initially but is non-ideal. It forces the +user/developer to construct the entire struct even if they need a small fraction +of the functionality. + +The following is an example of using a small subset of the SDK: + +```go +session, err := sessionClient.CurrentSession() +if err != nil { + return nil, err +} + +endpoints := sdk.Endpoints(session, serviceID) +``` + +#### Public vs Private Fields + +The goal of this `SDK` is to make all fields of concrete types public to the user +if there is a potential need for the user to set them directly. + +**IT SHOULD** be possible for the user to initialize any component of the SDK by +creating a struct and setting the bare minimum necessary fields. + +For example, the SDK biases towards the following design: + +```go +c := SessionClient { + HttpClient: myCustomHttpTransport +} +``` + +Instead of the following design: + +```go +c := NewSessionClient(nil, nil, myCustomHttpTransport, nil, nil) +``` ### Implementation Details @@ -213,4 +321,4 @@ Go, and they can be used as a reference for building more complex ones. The SDK relies on the `poktroll` repository for the `types` package, which acts as a single source of truth for the data structures used by the SDK. This design choice ensures consistency across the various components of the -POKT ecosystem. \ No newline at end of file +POKT ecosystem. diff --git a/block.go b/block.go new file mode 100644 index 0000000..0e19b94 --- /dev/null +++ b/block.go @@ -0,0 +1,64 @@ +// Package sdk implements utility functions for interacting with POKT full nodes. +package sdk + +import ( + "context" + "errors" + "fmt" + + ctypes "github.com/cometbft/cometbft/rpc/core/types" + cosmos "github.com/cosmos/cosmos-sdk/client" +) + +// TODO_IDEA: The BlockClient could leverage websockets to get notified about new blocks +// and cache the latest block height to avoid querying the blockchain for it every time. + +// BlockClient is a concrete types used to interact with the on-chain block module. +// For example, it can be used to get the latest block height. +// +// For obtaining the latest height, BlockClient uses a POKT full node's status +// which contains the latest block height. This is done to avoid fetching the +// entire latest block just to extract the block height. +type BlockClient struct { + // PoktNodeStatusFetcher specifies the functionality required by the + // BlockClient to interact with a POKT full node. + PoktNodeStatusFetcher +} + +// LatestBlockHeight returns the height of the latest committed block in the blockchain. +func (bc *BlockClient) LatestBlockHeight(ctx context.Context) (height int64, err error) { + if bc.PoktNodeStatusFetcher == nil { + return 0, errors.New("LatestBlockHeight: nil PoktNodeStatusFetcher") + } + + nodeStatus, err := bc.PoktNodeStatusFetcher.Status(ctx) + if err != nil { + return 0, err + } + + return nodeStatus.SyncInfo.LatestBlockHeight, nil +} + +// NewPoktNodeStatusFetcher returns the default implementation of the PoktNodeStatusFetcher interface. +// It connects, through a cometbft RPC HTTP client, to a POKT full node to get its status. +func NewPoktNodeStatusFetcher(queryNodeRpcUrl string) (PoktNodeStatusFetcher, error) { + // TODO_IMPROVE: drop the cosmos dependency and directly use cometbft rpchttp.New, once the latter publishes a release that includes this functionality. + // Directly using the cometbft will simplify the code by both reducing imported repos and removing the cosmos wrapper which we don't use. + // This can be done once there is a cometbft release that includes the following version: github.com/cometbft/cometbft v1.0.0-alpha.2.0.20240530055211-ae27f7eb3c08 + statusFetcher, err := cosmos.NewClientFromNode(queryNodeRpcUrl) + if err != nil { + return nil, fmt.Errorf("error constructing a default POKT full node status fetcher: %w", err) + } + + return statusFetcher, nil +} + +// PoktNodeStatusFetcher interface is used by the BlockClient to get the status of a POKT full node. +// The BlokClient extracts the latest height from this status struct. +// +// Most users can rely on the default implementation provided by NewPoktNodeStatusFetcher function. +// A custom implementation of this interface can be used to gain more granular control over the interactions +// of the BlockClient with the POKT full node. +type PoktNodeStatusFetcher interface { + Status(ctx context.Context) (*ctypes.ResultStatus, error) +} diff --git a/block_test.go b/block_test.go new file mode 100644 index 0000000..4966910 --- /dev/null +++ b/block_test.go @@ -0,0 +1,26 @@ +package sdk + +import ( + "context" + "fmt" +) + +func ExampleLatestBlockHeight() { + poktFullNode, err := NewPoktNodeStatusFetcher("pokt-full-node-URL") + if err != nil { + fmt.Printf("Erorr creating a connection to POKT full node: %v\n", err) + return + } + + bc := BlockClient{ + PoktNodeStatusFetcher: poktFullNode, + } + + queryHeight, err := bc.LatestBlockHeight(context.Background()) + if err != nil { + fmt.Printf("Erorr fetching latest block height: %v\n", err) + return + } + + fmt.Printf("Latest block height: %d\n", queryHeight) +} diff --git a/clients/block.go b/clients/block.go deleted file mode 100644 index 200c95e..0000000 --- a/clients/block.go +++ /dev/null @@ -1,40 +0,0 @@ -package client - -import ( - "context" - - rpchttp "github.com/cometbft/cometbft/rpc/client/http" - sdkclient "github.com/cosmos/cosmos-sdk/client" - - "github.com/pokt-network/shannon-sdk/sdk" -) - -var _ sdk.BlockClient = (*blockClient)(nil) - -// blockClient is a BlockClient implementation that uses the HTTP query client -// of the CosmosSDK. -type blockClient struct { - blockQueryClient *rpchttp.HTTP -} - -// NewBlockClient creates a new block client with the provided RPC URL. -func NewBlockClient(queryNodeRPCUrl string) (sdk.BlockClient, error) { - blockQueryClient, err := sdkclient.NewClientFromNode(queryNodeRPCUrl) - if err != nil { - return nil, err - } - - return &blockClient{ - blockQueryClient: blockQueryClient, - }, nil -} - -// GetLatestBlockHeight returns the height of the latest committed block in the blockchain. -func (bc *blockClient) GetLatestBlockHeight(ctx context.Context) (height int64, err error) { - block, err := bc.blockQueryClient.Block(ctx, nil) - if err != nil { - return 0, err - } - - return block.Block.Height, nil -} diff --git a/sdk/interface.go b/sdk/interface.go index 9ac31e4..2086a0e 100644 --- a/sdk/interface.go +++ b/sdk/interface.go @@ -33,18 +33,6 @@ type SharedParamsClient interface { GetParams(ctx context.Context) (params *sharedtypes.Params, err error) } -// BlockClient is the interface to interact with the on-chain block module. -// -// For example, it can be used to get the latest block height. -// -// The implementations of this interface could leverage websockets to get notified -// about new blocks and cache the latest block height to avoid querying the blockchain -// for it every time. -type BlockClient interface { - // GetLatestBlockHeight returns the height of the latest block. - GetLatestBlockHeight(ctx context.Context) (height int64, err error) -} - // RelayClient is the interface used to send Relays to suppliers. // // It is transport agnostic and could be implemented using the required protocols. diff --git a/sdk/sdk.go b/sdk/sdk.go index d24fdae..9ab8317 100644 --- a/sdk/sdk.go +++ b/sdk/sdk.go @@ -33,7 +33,6 @@ type ShannonSDK struct { sessionClient SessionClient accountClient AccountClient paramsClient SharedParamsClient - blockClient BlockClient relayClient RelayClient signer Signer } @@ -46,7 +45,6 @@ func NewShannonSDK( sessionClient SessionClient, accountClient AccountClient, paramsClient SharedParamsClient, - blockClient BlockClient, relayClient RelayClient, signer Signer, ) (*ShannonSDK, error) { @@ -55,7 +53,6 @@ func NewShannonSDK( sessionClient: sessionClient, accountClient: accountClient, paramsClient: paramsClient, - blockClient: blockClient, relayClient: relayClient, signer: signer, }, nil @@ -68,13 +65,9 @@ func (sdk *ShannonSDK) GetSessionSupplierEndpoints( ctx context.Context, appAddress string, serviceId string, + queryHeight int64, ) (sessionSuppliers *types.SessionSuppliers, err error) { - latestHeight, err := sdk.blockClient.GetLatestBlockHeight(ctx) - if err != nil { - return nil, err - } - - currentSession, err := sdk.sessionClient.GetSession(ctx, appAddress, serviceId, latestHeight) + currentSession, err := sdk.sessionClient.GetSession(ctx, appAddress, serviceId, queryHeight) if err != nil { return nil, err } @@ -112,6 +105,7 @@ func (sdk *ShannonSDK) GetSessionSupplierEndpoints( func (sdk *ShannonSDK) GetApplicationsDelegatingToGateway( ctx context.Context, gatewayAddress string, + queryHeight int64, ) ([]string, error) { // TODO_DISCUSS: remove this call: pass to this function the list of Application structs, which can be obtained separately using the ApplicationClient. // It can be composed using other basic components of the SDK, e.g. get all the applications, get the latest block height, etc. @@ -122,11 +116,6 @@ func (sdk *ShannonSDK) GetApplicationsDelegatingToGateway( return nil, err } - currentHeight, err := sdk.blockClient.GetLatestBlockHeight(ctx) - if err != nil { - return nil, err - } - params, err := sdk.paramsClient.GetParams(ctx) if err != nil { return nil, err @@ -135,8 +124,8 @@ func (sdk *ShannonSDK) GetApplicationsDelegatingToGateway( gatewayDelegatingApplications := make([]string, 0) for _, application := range allApplications { // Get the gateways that are currently delegated to the application - // at the current height and check if the given gateway address is in the list. - gatewaysDelegatedTo := rings.GetRingAddressesAtBlock(params, &application, currentHeight) + // at the query height and check if the given gateway address is in the list. + gatewaysDelegatedTo := rings.GetRingAddressesAtBlock(params, &application, queryHeight) if slices.Contains(gatewaysDelegatedTo, gatewayAddress) { // The application is delegating to the given gateway address, add it to the list. gatewayDelegatingApplications = append(gatewayDelegatingApplications, application.Address) @@ -153,6 +142,7 @@ func (sdk *ShannonSDK) SendRelay( ctx context.Context, sessionSupplierEndpoint *types.SingleSupplierEndpoint, requestBz []byte, + queryHeight int64, ) (relayResponse *servicetypes.RelayResponse, err error) { if err := sessionSupplierEndpoint.SessionHeader.ValidateBasic(); err != nil { return nil, err @@ -167,7 +157,7 @@ func (sdk *ShannonSDK) SendRelay( Payload: requestBz, } - relayRequestSig, err := sdk.signRelayRequest(ctx, relayRequest) + relayRequestSig, err := sdk.signRelayRequest(ctx, relayRequest, queryHeight) if err != nil { return nil, err } @@ -219,10 +209,11 @@ func (sdk *ShannonSDK) SendRelay( func (sdk *ShannonSDK) signRelayRequest( ctx context.Context, relayRequest *servicetypes.RelayRequest, + queryHeight int64, ) (signature []byte, err error) { appAddress := relayRequest.GetMeta().SessionHeader.GetApplicationAddress() - appRing, err := sdk.getRingForApplicationAddress(ctx, appAddress) + appRing, err := sdk.getRingForApplicationAddress(ctx, appAddress, queryHeight) if err != nil { return nil, err } @@ -256,6 +247,7 @@ func (sdk *ShannonSDK) signRelayRequest( func (sdk *ShannonSDK) getRingForApplicationAddress( ctx context.Context, appAddress string, + queryHeight int64, ) (addressRing *ring.Ring, err error) { // TODO_DISCUSS: It may be a good idea to remove this call, and pass the application struct to this function, instead of an address. application, err := sdk.ApplicationLister.GetApplication(ctx, appAddress) @@ -263,11 +255,6 @@ func (sdk *ShannonSDK) getRingForApplicationAddress( return nil, err } - latestHeight, err := sdk.blockClient.GetLatestBlockHeight(ctx) - if err != nil { - return nil, err - } - params, err := sdk.paramsClient.GetParams(ctx) if err != nil { return nil, err @@ -275,7 +262,7 @@ func (sdk *ShannonSDK) getRingForApplicationAddress( // Get the current gateway addresses that are delegated from the application // at the latest height. - currentGatewayAddresses := rings.GetRingAddressesAtBlock(params, &application, latestHeight) + currentGatewayAddresses := rings.GetRingAddressesAtBlock(params, &application, queryHeight) ringAddresses := make([]string, 0) ringAddresses = append(ringAddresses, application.Address) diff --git a/types/codec_test.go b/types/codec_test.go index 4500ae0..18e505b 100644 --- a/types/codec_test.go +++ b/types/codec_test.go @@ -42,7 +42,8 @@ func TestCodec_SerializeRequest_Success(t *testing.T) { poktReq, poktReqBz, err := types.SerializeHTTPRequest(req) require.NoError(t, err) - marshalledPOKTReqBz, err := proto.Marshal(poktReq) + opts := proto.MarshalOptions{Deterministic: true} + marshalledPOKTReqBz, err := opts.Marshal(poktReq) require.NoError(t, err) for key := range req.Header { @@ -118,7 +119,8 @@ func TestCodec_SerializeResponse_Success(t *testing.T) { poktRes, poktResBz, err := types.SerializeHTTPResponse(res) require.NoError(t, err) - marshalledPOKTResBz, err := proto.Marshal(poktRes) + opts := proto.MarshalOptions{Deterministic: true} + marshalledPOKTResBz, err := opts.Marshal(poktRes) require.NoError(t, err) for key := range res.Header { diff --git a/types/request.go b/types/request.go index 98d7e7f..14c466b 100644 --- a/types/request.go +++ b/types/request.go @@ -3,7 +3,6 @@ package types import ( "io" "net/http" - "slices" "google.golang.org/protobuf/proto" ) @@ -21,11 +20,7 @@ func SerializeHTTPRequest( headers := map[string]*Header{} for key := range request.Header { - // Sort the header values to ensure that the order of the values is - // consistent and byte-for-byte equal when comparing the serialized - // request. headerValues := request.Header.Values(key) - slices.Sort(headerValues) headers[key] = &Header{ Key: key, Values: headerValues, @@ -39,7 +34,11 @@ func SerializeHTTPRequest( BodyBz: requestBodyBz, } - poktHTTPRequestBz, err = proto.Marshal(poktHTTPRequest) + // Use deterministic marshalling to ensure that the serialized request is + // byte-for-byte equal when comparing the serialized request. + opts := proto.MarshalOptions{Deterministic: true} + + poktHTTPRequestBz, err = opts.Marshal(poktHTTPRequest) return poktHTTPRequest, poktHTTPRequestBz, err } diff --git a/types/response.go b/types/response.go index f88f4d7..aa19edc 100644 --- a/types/response.go +++ b/types/response.go @@ -3,7 +3,6 @@ package types import ( "io" "net/http" - "slices" "google.golang.org/protobuf/proto" ) @@ -25,11 +24,7 @@ func SerializeHTTPResponse( // We have to avoid using http.Header.Get(key) because it only returns the // first value of the key. for key := range response.Header { - // Sort the header values to ensure that the order of the values is - // consistent and byte-for-byte equal when comparing the serialized - // response. headerValues := response.Header.Values(key) - slices.Sort(headerValues) headers[key] = &Header{ Key: key, Values: headerValues, @@ -42,7 +37,11 @@ func SerializeHTTPResponse( BodyBz: responseBodyBz, } - poktHTTPResponseBz, err = proto.Marshal(poktHTTPResponse) + // Use deterministic marshalling to ensure that the serialized response is + // byte-for-byte equal when comparing the serialized response. + opts := proto.MarshalOptions{Deterministic: true} + + poktHTTPResponseBz, err = opts.Marshal(poktHTTPResponse) return poktHTTPResponse, poktHTTPResponseBz, err }