github.com/kaleido-io/firefly@v0.0.0-20210622132723-8b4b6aacb971/internal/blockchain/ethereum/ethereum.go (about) 1 // Copyright © 2021 Kaleido, Inc. 2 // 3 // SPDX-License-Identifier: Apache-2.0 4 // 5 // Licensed under the Apache License, Version 2.0 (the "License"); 6 // you may not use this file except in compliance with the License. 7 // You may obtain a copy of the License at 8 // 9 // http://www.apache.org/licenses/LICENSE-2.0 10 // 11 // Unless required by applicable law or agreed to in writing, software 12 // distributed under the License is distributed on an "AS IS" BASIS, 13 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 // See the License for the specific language governing permissions and 15 // limitations under the License. 16 17 package ethereum 18 19 import ( 20 "context" 21 "encoding/hex" 22 "encoding/json" 23 "fmt" 24 "regexp" 25 "strings" 26 27 "github.com/go-resty/resty/v2" 28 "github.com/kaleido-io/firefly/internal/config" 29 "github.com/kaleido-io/firefly/internal/i18n" 30 "github.com/kaleido-io/firefly/internal/log" 31 "github.com/kaleido-io/firefly/internal/restclient" 32 "github.com/kaleido-io/firefly/internal/wsclient" 33 "github.com/kaleido-io/firefly/pkg/blockchain" 34 "github.com/kaleido-io/firefly/pkg/fftypes" 35 ) 36 37 const ( 38 broadcastBatchEventSignature = "BatchPin(address,uint256,string,bytes32,bytes32,bytes32,bytes32[])" 39 ) 40 41 var zeroBytes32 = fftypes.Bytes32{} 42 43 type Ethereum struct { 44 ctx context.Context 45 topic string 46 instancePath string 47 capabilities *blockchain.Capabilities 48 callbacks blockchain.Callbacks 49 client *resty.Client 50 initInfo struct { 51 stream *eventStream 52 subs []*subscription 53 } 54 wsconn wsclient.WSClient 55 } 56 57 type eventStream struct { 58 ID string `json:"id"` 59 Name string `json:"name"` 60 ErrorHandling string `json:"errorHandling"` 61 BatchSize uint `json:"batchSize"` 62 BatchTimeoutMS uint `json:"batchTimeoutMS"` 63 Type string `json:"type"` 64 WebSocket eventStreamWebsocket `json:"websocket"` 65 } 66 67 type eventStreamWebsocket struct { 68 Topic string `json:"topic"` 69 } 70 71 type subscription struct { 72 ID string `json:"id"` 73 Description string `json:"description"` 74 Name string `json:"name"` 75 StreamID string `json:"streamID"` 76 Stream string `json:"stream"` 77 FromBlock string `json:"fromBlock"` 78 } 79 80 type asyncTXSubmission struct { 81 ID string `json:"id"` 82 } 83 84 type ethBatchPinInput struct { 85 Namespace string `json:"namespace"` 86 UUIDs string `json:"uuids"` 87 BatchHash string `json:"batchHash"` 88 PayloadRef string `json:"payloadRef"` 89 Contexts []string `json:"contexts"` 90 } 91 92 type ethWSCommandPayload struct { 93 Type string `json:"type"` 94 Topic string `json:"topic,omitempty"` 95 } 96 97 var requiredSubscriptions = map[string]string{ 98 "BatchPin": "Batch pin", 99 } 100 101 var addressVerify = regexp.MustCompile("^[0-9a-f]{40}$") 102 103 func (e *Ethereum) Name() string { 104 return "ethereum" 105 } 106 107 func (e *Ethereum) Init(ctx context.Context, prefix config.Prefix, callbacks blockchain.Callbacks) (err error) { 108 109 ethconnectConf := prefix.SubPrefix(EthconnectConfigKey) 110 111 e.ctx = log.WithLogField(ctx, "proto", "ethereum") 112 e.callbacks = callbacks 113 114 if ethconnectConf.GetString(restclient.HTTPConfigURL) == "" { 115 return i18n.NewError(ctx, i18n.MsgMissingPluginConfig, "url", "blockchain.ethconnect") 116 } 117 e.instancePath = ethconnectConf.GetString(EthconnectConfigInstancePath) 118 if e.instancePath == "" { 119 return i18n.NewError(ctx, i18n.MsgMissingPluginConfig, "instance", "blockchain.ethconnect") 120 } 121 e.topic = ethconnectConf.GetString(EthconnectConfigTopic) 122 if e.topic == "" { 123 return i18n.NewError(ctx, i18n.MsgMissingPluginConfig, "topic", "blockchain.ethconnect") 124 } 125 126 e.client = restclient.New(e.ctx, ethconnectConf) 127 e.capabilities = &blockchain.Capabilities{ 128 GlobalSequencer: true, 129 } 130 131 if ethconnectConf.GetString(wsclient.WSConfigKeyPath) == "" { 132 ethconnectConf.Set(wsclient.WSConfigKeyPath, "/ws") 133 } 134 e.wsconn, err = wsclient.New(ctx, ethconnectConf, e.afterConnect) 135 if err != nil { 136 return err 137 } 138 139 if !ethconnectConf.GetBool(EthconnectConfigSkipEventstreamInit) { 140 if err = e.ensureEventStreams(ethconnectConf); err != nil { 141 return err 142 } 143 } 144 145 go e.eventLoop() 146 147 return nil 148 } 149 150 func (e *Ethereum) Start() error { 151 return e.wsconn.Connect() 152 } 153 154 func (e *Ethereum) Capabilities() *blockchain.Capabilities { 155 return e.capabilities 156 } 157 158 func (e *Ethereum) ensureEventStreams(ethconnectConf config.Prefix) error { 159 160 var existingStreams []*eventStream 161 res, err := e.client.R().SetContext(e.ctx).SetResult(&existingStreams).Get("/eventstreams") 162 if err != nil || !res.IsSuccess() { 163 return restclient.WrapRestErr(e.ctx, res, err, i18n.MsgEthconnectRESTErr) 164 } 165 166 for _, stream := range existingStreams { 167 if stream.WebSocket.Topic == e.topic { 168 e.initInfo.stream = stream 169 } 170 } 171 172 if e.initInfo.stream == nil { 173 newStream := eventStream{ 174 Name: e.topic, 175 ErrorHandling: "block", 176 BatchSize: ethconnectConf.GetUint(EthconnectConfigBatchSize), 177 BatchTimeoutMS: uint(ethconnectConf.GetDuration(EthconnectConfigBatchTimeout).Milliseconds()), 178 Type: "websocket", 179 } 180 newStream.WebSocket.Topic = e.topic 181 res, err = e.client.R().SetBody(&newStream).SetResult(&newStream).Post("/eventstreams") 182 if err != nil || !res.IsSuccess() { 183 return restclient.WrapRestErr(e.ctx, res, err, i18n.MsgEthconnectRESTErr) 184 } 185 e.initInfo.stream = &newStream 186 } 187 188 log.L(e.ctx).Infof("Event stream: %s", e.initInfo.stream.ID) 189 190 return e.ensureSusbscriptions(e.initInfo.stream.ID) 191 } 192 193 func (e *Ethereum) afterConnect(ctx context.Context, w wsclient.WSClient) error { 194 // Send a subscribe to our topic after each connect/reconnect 195 b, _ := json.Marshal(ðWSCommandPayload{ 196 Type: "listen", 197 Topic: e.topic, 198 }) 199 err := w.Send(ctx, b) 200 if err == nil { 201 b, _ = json.Marshal(ðWSCommandPayload{ 202 Type: "listenreplies", 203 }) 204 err = w.Send(ctx, b) 205 } 206 return err 207 } 208 209 func (e *Ethereum) ensureSusbscriptions(streamID string) error { 210 for eventType, subDesc := range requiredSubscriptions { 211 212 var existingSubs []*subscription 213 res, err := e.client.R().SetResult(&existingSubs).Get("/subscriptions") 214 if err != nil || !res.IsSuccess() { 215 return restclient.WrapRestErr(e.ctx, res, err, i18n.MsgEthconnectRESTErr) 216 } 217 218 var sub *subscription 219 for _, s := range existingSubs { 220 if s.Name == eventType { 221 sub = s 222 } 223 } 224 225 if sub == nil { 226 newSub := subscription{ 227 Name: eventType, 228 Description: subDesc, 229 StreamID: streamID, 230 Stream: e.initInfo.stream.ID, 231 FromBlock: "0", 232 } 233 res, err = e.client.R(). 234 SetContext(e.ctx). 235 SetBody(&newSub). 236 SetResult(&newSub). 237 Post(fmt.Sprintf("%s/%s", e.instancePath, eventType)) 238 if err != nil || !res.IsSuccess() { 239 return restclient.WrapRestErr(e.ctx, res, err, i18n.MsgEthconnectRESTErr) 240 } 241 sub = &newSub 242 } 243 244 log.L(e.ctx).Infof("%s subscription: %s", eventType, sub.ID) 245 e.initInfo.subs = append(e.initInfo.subs, sub) 246 247 } 248 return nil 249 } 250 251 func ethHexFormatB32(b *fftypes.Bytes32) string { 252 if b == nil { 253 return "0x0000000000000000000000000000000000000000000000000000000000000000" 254 } 255 return "0x" + hex.EncodeToString(b[0:32]) 256 } 257 258 func (e *Ethereum) handleBatchPinEvent(ctx context.Context, msgJSON fftypes.JSONObject) (err error) { 259 sBlockNumber := msgJSON.GetString("blockNumber") 260 sTransactionIndex := msgJSON.GetString("transactionIndex") 261 sTransactionHash := msgJSON.GetString("transactionHash") 262 dataJSON := msgJSON.GetObject("data") 263 authorAddress := dataJSON.GetString("author") 264 ns := dataJSON.GetString("namespace") 265 sUUIDs := dataJSON.GetString("uuids") 266 sBatchHash := dataJSON.GetString("batchHash") 267 sPayloadRef := dataJSON.GetString("payloadRef") 268 sContexts := dataJSON.GetStringArray("contexts") 269 270 if sBlockNumber == "" || 271 sTransactionIndex == "" || 272 sTransactionHash == "" || 273 authorAddress == "" || 274 sUUIDs == "" || 275 sBatchHash == "" || 276 sPayloadRef == "" { 277 log.L(ctx).Errorf("BatchPin event is not valid - missing data: %+v", msgJSON) 278 return nil // move on 279 } 280 281 authorAddress, err = e.validateEthAddress(ctx, authorAddress) 282 if err != nil { 283 log.L(ctx).Errorf("BatchPin event is not valid - bad from address (%s): %+v", err, msgJSON) 284 return nil // move on 285 } 286 287 hexUUIDs, err := hex.DecodeString(strings.TrimPrefix(sUUIDs, "0x")) 288 if err != nil || len(hexUUIDs) != 32 { 289 log.L(ctx).Errorf("BatchPin event is not valid - bad uuids (%s): %+v", err, msgJSON) 290 return nil // move on 291 } 292 var txnID fftypes.UUID 293 copy(txnID[:], hexUUIDs[0:16]) 294 var batchID fftypes.UUID 295 copy(batchID[:], hexUUIDs[16:32]) 296 297 var batchHash fftypes.Bytes32 298 err = batchHash.UnmarshalText([]byte(sBatchHash)) 299 if err != nil { 300 log.L(ctx).Errorf("BatchPin event is not valid - bad batchHash (%s): %+v", err, msgJSON) 301 return nil // move on 302 } 303 304 var payloadRef fftypes.Bytes32 305 err = payloadRef.UnmarshalText([]byte(sPayloadRef)) 306 if err != nil { 307 log.L(ctx).Errorf("BatchPin event is not valid - bad payloadRef (%s): %+v", err, msgJSON) 308 return nil // move on 309 } 310 payloadRefOrNil := &payloadRef 311 if *payloadRefOrNil == zeroBytes32 { 312 payloadRefOrNil = nil 313 } 314 315 contexts := make([]*fftypes.Bytes32, len(sContexts)) 316 for i, sHash := range sContexts { 317 var hash fftypes.Bytes32 318 err = hash.UnmarshalText([]byte(sHash)) 319 if err != nil { 320 log.L(ctx).Errorf("BatchPin event is not valid - bad pin %d (%s): %+v", i, err, msgJSON) 321 return nil // move on 322 } 323 contexts[i] = &hash 324 } 325 326 batch := &blockchain.BatchPin{ 327 Namespace: ns, 328 TransactionID: &txnID, 329 BatchID: &batchID, 330 BatchHash: &batchHash, 331 BatchPaylodRef: payloadRefOrNil, 332 Contexts: contexts, 333 } 334 335 // If there's an error dispatching the event, we must return the error and shutdown 336 return e.callbacks.BatchPinComplete(batch, authorAddress, sTransactionHash, msgJSON) 337 } 338 339 func (e *Ethereum) handleReceipt(ctx context.Context, reply fftypes.JSONObject) error { 340 l := log.L(ctx) 341 342 headers := reply.GetObject("headers") 343 requestID := headers.GetString("requestId") 344 replyType := headers.GetString("type") 345 txHash := reply.GetString("transactionHash") 346 message := reply.GetString("errorMessage") 347 if requestID == "" || replyType == "" { 348 l.Errorf("Reply cannot be processed: %+v", reply) 349 return nil // Swallow this and move on 350 } 351 updateType := fftypes.OpStatusSucceeded 352 if replyType != "TransactionSuccess" { 353 updateType = fftypes.OpStatusFailed 354 } 355 l.Infof("Ethconnect '%s' reply tx=%s (request=%s) %s", replyType, txHash, requestID, message) 356 return e.callbacks.TxSubmissionUpdate(requestID, updateType, txHash, message, reply) 357 } 358 359 func (e *Ethereum) handleMessageBatch(ctx context.Context, messages []interface{}) error { 360 l := log.L(ctx) 361 362 for i, msgI := range messages { 363 msgMap, ok := msgI.(map[string]interface{}) 364 if !ok { 365 l.Errorf("Message cannot be parsed as JSON: %+v", msgI) 366 return nil // Swallow this and move on 367 } 368 msgJSON := fftypes.JSONObject(msgMap) 369 370 l1 := l.WithField("ethmsgidx", i) 371 ctx1 := log.WithLogger(ctx, l1) 372 signature := msgJSON.GetString("signature") 373 l1.Infof("Received '%s' message", signature) 374 l1.Tracef("Message: %+v", msgJSON) 375 376 switch signature { 377 case broadcastBatchEventSignature: 378 if err := e.handleBatchPinEvent(ctx1, msgJSON); err != nil { 379 return err 380 } 381 default: 382 l.Infof("Ignoring event with unknown signature: %s", signature) 383 } 384 } 385 386 return nil 387 } 388 389 func (e *Ethereum) eventLoop() { 390 l := log.L(e.ctx).WithField("role", "event-loop") 391 ctx := log.WithLogger(e.ctx, l) 392 ack, _ := json.Marshal(map[string]string{"type": "ack", "topic": e.topic}) 393 for { 394 select { 395 case <-ctx.Done(): 396 l.Debugf("Event loop exiting (context cancelled)") 397 return 398 case msgBytes, ok := <-e.wsconn.Receive(): 399 if !ok { 400 l.Debugf("Event loop exiting (receive channel closed)") 401 return 402 } 403 404 var msgParsed interface{} 405 err := json.Unmarshal(msgBytes, &msgParsed) 406 if err != nil { 407 l.Errorf("Message cannot be parsed as JSON: %s\n%s", err, string(msgBytes)) 408 continue // Swallow this and move on 409 } 410 switch msgTyped := msgParsed.(type) { 411 case []interface{}: 412 err = e.handleMessageBatch(ctx, msgTyped) 413 if err == nil { 414 err = e.wsconn.Send(ctx, ack) 415 } 416 case map[string]interface{}: 417 err = e.handleReceipt(ctx, fftypes.JSONObject(msgTyped)) 418 default: 419 l.Errorf("Message unexpected: %+v", msgTyped) 420 continue 421 } 422 423 // Send the ack - only fails if shutting down 424 if err != nil { 425 l.Errorf("Event loop exiting: %s", err) 426 return 427 } 428 } 429 } 430 } 431 432 func (e *Ethereum) VerifyIdentitySyntax(ctx context.Context, identity *fftypes.Identity) (err error) { 433 identity.OnChain, err = e.validateEthAddress(ctx, identity.OnChain) 434 return 435 } 436 437 func (e *Ethereum) validateEthAddress(ctx context.Context, identity string) (string, error) { 438 identity = strings.TrimPrefix(strings.ToLower(identity), "0x") 439 if !addressVerify.MatchString(identity) { 440 return "", i18n.NewError(ctx, i18n.MsgInvalidEthAddress) 441 } 442 return "0x" + identity, nil 443 } 444 445 func (e *Ethereum) SubmitBatchPin(ctx context.Context, ledgerID *fftypes.UUID, identity *fftypes.Identity, batch *blockchain.BatchPin) (txTrackingID string, err error) { 446 tx := &asyncTXSubmission{} 447 ethHashes := make([]string, len(batch.Contexts)) 448 for i, v := range batch.Contexts { 449 ethHashes[i] = ethHexFormatB32(v) 450 } 451 var uuids fftypes.Bytes32 452 copy(uuids[0:16], (*batch.TransactionID)[:]) 453 copy(uuids[16:32], (*batch.BatchID)[:]) 454 input := ðBatchPinInput{ 455 Namespace: batch.Namespace, 456 UUIDs: ethHexFormatB32(&uuids), 457 BatchHash: ethHexFormatB32(batch.BatchHash), 458 PayloadRef: ethHexFormatB32(batch.BatchPaylodRef), 459 Contexts: ethHashes, 460 } 461 path := fmt.Sprintf("%s/pinBatch", e.instancePath) 462 res, err := e.client.R(). 463 SetContext(ctx). 464 SetQueryParam("fly-from", identity.OnChain). 465 SetQueryParam("fly-sync", "false"). 466 SetBody(input). 467 SetResult(tx). 468 Post(path) 469 if err != nil || !res.IsSuccess() { 470 return "", restclient.WrapRestErr(ctx, res, err, i18n.MsgEthconnectRESTErr) 471 } 472 return tx.ID, nil 473 }