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(&ethWSCommandPayload{
   196  		Type:  "listen",
   197  		Topic: e.topic,
   198  	})
   199  	err := w.Send(ctx, b)
   200  	if err == nil {
   201  		b, _ = json.Marshal(&ethWSCommandPayload{
   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 := &ethBatchPinInput{
   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  }