github.com/badrootd/celestia-core@v0.0.0-20240305091328-aa4207a4b25d/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/celestia-core/light/provider" 12 rpcclient "github.com/badrootd/celestia-core/rpc/client" 13 rpchttp "github.com/badrootd/celestia-core/rpc/client/http" 14 "github.com/badrootd/celestia-core/types" 15 ) 16 17 var ( 18 // This is very brittle, see: https://github.com/cometbft/cometbft/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 // NOTE: it seems like the context errors are being wrapped in a way that 164 // makes them hard to detect. For now, we just check the error string. 165 case strings.Contains(err.Error(), context.DeadlineExceeded.Error()): 166 return nil, context.DeadlineExceeded 167 168 case ctx.Err() != nil: 169 return nil, ctx.Err() 170 171 // context canceled or connection refused we return the error 172 default: 173 return nil, err 174 } 175 176 } 177 } 178 179 valSet, err := types.ValidatorSetFromExistingValidators(vals) 180 if err != nil { 181 return nil, provider.ErrBadLightBlock{Reason: err} 182 } 183 return valSet, nil 184 } 185 186 func (p *http) signedHeader(ctx context.Context, height *int64) (*types.SignedHeader, error) { 187 for attempt := 1; attempt <= maxRetryAttempts; attempt++ { 188 commit, err := p.client.Commit(ctx, height) 189 switch { 190 case err == nil: 191 // See https://github.com/cometbft/cometbft/issues/575 192 // If the node is starting at a non-zero height, but does not yet 193 // have any blocks, it can return an empty signed header without 194 // returning an error. 195 if commit.SignedHeader.IsEmpty() { 196 // Technically this means that the provider still needs to 197 // catch up. 198 return nil, provider.ErrHeightTooHigh 199 } 200 return &commit.SignedHeader, nil 201 202 case regexpTooHigh.MatchString(err.Error()): 203 return nil, provider.ErrHeightTooHigh 204 205 case regexpMissingHeight.MatchString(err.Error()): 206 return nil, provider.ErrLightBlockNotFound 207 208 case regexpTimedOut.MatchString(err.Error()): 209 // we wait and try again with exponential backoff 210 time.Sleep(backoffTimeout(uint16(attempt))) 211 continue 212 213 // NOTE: it seems like the context errors are being wrapped in a way that 214 // makes them hard to detect. For now, we just check the error string. 215 case strings.Contains(err.Error(), context.DeadlineExceeded.Error()): 216 return nil, context.DeadlineExceeded 217 218 case ctx.Err() != nil: 219 return nil, ctx.Err() 220 221 // either context was cancelled or connection refused. 222 default: 223 return nil, err 224 } 225 } 226 return nil, provider.ErrNoResponse 227 } 228 229 func validateHeight(height int64) (*int64, error) { 230 if height < 0 { 231 return nil, fmt.Errorf("expected height >= 0, got height %d", height) 232 } 233 234 h := &height 235 if height == 0 { 236 h = nil 237 } 238 return h, nil 239 } 240 241 // exponential backoff (with jitter) 242 // 0.5s -> 2s -> 4.5s -> 8s -> 12.5 with 1s variation 243 func backoffTimeout(attempt uint16) time.Duration { 244 //nolint:gosec // G404: Use of weak random number generator 245 return time.Duration(500*attempt*attempt)*time.Millisecond + time.Duration(rand.Intn(1000))*time.Millisecond 246 }