github.com/badrootd/nibiru-cometbft@v0.37.5-0.20240307173500-2a75559eee9b/light/provider/http/http.go (about) 1 package http 2 3 import ( 4 "context" 5 "fmt" 6 "math/rand" 7 "regexp" 8 "strings" 9 "time" 10 11 "github.com/badrootd/nibiru-cometbft/light/provider" 12 rpcclient "github.com/badrootd/nibiru-cometbft/rpc/client" 13 rpchttp "github.com/badrootd/nibiru-cometbft/rpc/client/http" 14 "github.com/badrootd/nibiru-cometbft/types" 15 ) 16 17 var ( 18 // This is very brittle, see: https://github.com/tendermint/tendermint/issues/4740 19 regexpMissingHeight = regexp.MustCompile(`height \d+ is not available`) 20 regexpTooHigh = regexp.MustCompile(`height \d+ must be less than or equal to`) 21 regexpTimedOut = regexp.MustCompile(`Timeout exceeded`) 22 23 maxRetryAttempts = 5 24 timeout uint = 5 // sec. 25 ) 26 27 // http provider uses an RPC client to obtain the necessary information. 28 type http struct { 29 chainID string 30 client rpcclient.RemoteClient 31 } 32 33 // New creates a HTTP provider, which is using the rpchttp.HTTP client under 34 // the hood. If no scheme is provided in the remote URL, http will be used by 35 // default. The 5s timeout is used for all requests. 36 func New(chainID, remote string) (provider.Provider, error) { 37 // Ensure URL scheme is set (default HTTP) when not provided. 38 if !strings.Contains(remote, "://") { 39 remote = "http://" + remote 40 } 41 42 httpClient, err := rpchttp.NewWithTimeout(remote, "/websocket", timeout) 43 if err != nil { 44 return nil, err 45 } 46 47 return NewWithClient(chainID, httpClient), nil 48 } 49 50 // NewWithClient allows you to provide a custom client. 51 func NewWithClient(chainID string, client rpcclient.RemoteClient) provider.Provider { 52 return &http{ 53 client: client, 54 chainID: chainID, 55 } 56 } 57 58 // ChainID returns a chainID this provider was configured with. 59 func (p *http) ChainID() string { 60 return p.chainID 61 } 62 63 func (p *http) String() string { 64 return fmt.Sprintf("http{%s}", p.client.Remote()) 65 } 66 67 // LightBlock fetches a LightBlock at the given height and checks the 68 // chainID matches. 69 func (p *http) LightBlock(ctx context.Context, height int64) (*types.LightBlock, error) { 70 h, err := validateHeight(height) 71 if err != nil { 72 return nil, provider.ErrBadLightBlock{Reason: err} 73 } 74 75 sh, err := p.signedHeader(ctx, h) 76 if err != nil { 77 return nil, err 78 } 79 80 if height != 0 && sh.Height != height { 81 return nil, provider.ErrBadLightBlock{ 82 Reason: fmt.Errorf("height %d responded doesn't match height %d requested", sh.Height, height), 83 } 84 } 85 86 vs, err := p.validatorSet(ctx, &sh.Height) 87 if err != nil { 88 return nil, err 89 } 90 91 lb := &types.LightBlock{ 92 SignedHeader: sh, 93 ValidatorSet: vs, 94 } 95 96 err = lb.ValidateBasic(p.chainID) 97 if err != nil { 98 return nil, provider.ErrBadLightBlock{Reason: err} 99 } 100 101 return lb, nil 102 } 103 104 // ReportEvidence calls `/broadcast_evidence` endpoint. 105 func (p *http) ReportEvidence(ctx context.Context, ev types.Evidence) error { 106 _, err := p.client.BroadcastEvidence(ctx, ev) 107 return err 108 } 109 110 func (p *http) validatorSet(ctx context.Context, height *int64) (*types.ValidatorSet, error) { 111 // Since the malicious node could report a massive number of pages, making us 112 // spend a considerable time iterating, we restrict the number of pages here. 113 // => 10000 validators max 114 const maxPages = 100 115 116 var ( 117 perPage = 100 118 vals = []*types.Validator{} 119 page = 1 120 total = -1 121 ) 122 123 OUTER_LOOP: 124 for len(vals) != total && page <= maxPages { 125 for attempt := 1; attempt <= maxRetryAttempts; attempt++ { 126 res, err := p.client.Validators(ctx, height, &page, &perPage) 127 switch { 128 case err == nil: 129 // Validate response. 130 if len(res.Validators) == 0 { 131 return nil, provider.ErrBadLightBlock{ 132 Reason: fmt.Errorf("validator set is empty (height: %d, page: %d, per_page: %d)", 133 height, page, perPage), 134 } 135 } 136 if res.Total <= 0 { 137 return nil, provider.ErrBadLightBlock{ 138 Reason: fmt.Errorf("total number of vals is <= 0: %d (height: %d, page: %d, per_page: %d)", 139 res.Total, height, page, perPage), 140 } 141 } 142 143 total = res.Total 144 vals = append(vals, res.Validators...) 145 page++ 146 continue OUTER_LOOP 147 148 case regexpTooHigh.MatchString(err.Error()): 149 return nil, provider.ErrHeightTooHigh 150 151 case regexpMissingHeight.MatchString(err.Error()): 152 return nil, provider.ErrLightBlockNotFound 153 154 // if we have exceeded retry attempts then return no response error 155 case attempt == maxRetryAttempts: 156 return nil, provider.ErrNoResponse 157 158 case regexpTimedOut.MatchString(err.Error()): 159 // we wait and try again with exponential backoff 160 time.Sleep(backoffTimeout(uint16(attempt))) 161 continue 162 163 // context canceled or connection refused we return the error 164 default: 165 return nil, err 166 } 167 168 } 169 } 170 171 valSet, err := types.ValidatorSetFromExistingValidators(vals) 172 if err != nil { 173 return nil, provider.ErrBadLightBlock{Reason: err} 174 } 175 return valSet, nil 176 } 177 178 func (p *http) signedHeader(ctx context.Context, height *int64) (*types.SignedHeader, error) { 179 for attempt := 1; attempt <= maxRetryAttempts; attempt++ { 180 commit, err := p.client.Commit(ctx, height) 181 switch { 182 case err == nil: 183 // See https://github.com/cometbft/cometbft/issues/575 184 // If the node is starting at a non-zero height, but does not yet 185 // have any blocks, it can return an empty signed header without 186 // returning an error. 187 if commit.SignedHeader.IsEmpty() { 188 // Technically this means that the provider still needs to 189 // catch up. 190 return nil, provider.ErrHeightTooHigh 191 } 192 return &commit.SignedHeader, nil 193 194 case regexpTooHigh.MatchString(err.Error()): 195 return nil, provider.ErrHeightTooHigh 196 197 case regexpMissingHeight.MatchString(err.Error()): 198 return nil, provider.ErrLightBlockNotFound 199 200 case regexpTimedOut.MatchString(err.Error()): 201 // we wait and try again with exponential backoff 202 time.Sleep(backoffTimeout(uint16(attempt))) 203 continue 204 205 // either context was canceled or connection refused. 206 default: 207 return nil, err 208 } 209 } 210 return nil, provider.ErrNoResponse 211 } 212 213 func validateHeight(height int64) (*int64, error) { 214 if height < 0 { 215 return nil, fmt.Errorf("expected height >= 0, got height %d", height) 216 } 217 218 h := &height 219 if height == 0 { 220 h = nil 221 } 222 return h, nil 223 } 224 225 // exponential backoff (with jitter) 226 // 0.5s -> 2s -> 4.5s -> 8s -> 12.5 with 1s variation 227 func backoffTimeout(attempt uint16) time.Duration { 228 //nolint:gosec // G404: Use of weak random number generator 229 return time.Duration(500*attempt*attempt)*time.Millisecond + time.Duration(rand.Intn(1000))*time.Millisecond 230 }