wormhole/node/pkg/watchers/cosmwasm/watcher.go

495 lines
17 KiB
Go

package cosmwasm
import (
"context"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net/http"
"strconv"
"time"
"github.com/certusone/wormhole/node/pkg/p2p"
gossipv1 "github.com/certusone/wormhole/node/pkg/proto/gossip/v1"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus"
eth_common "github.com/ethereum/go-ethereum/common"
"github.com/certusone/wormhole/node/pkg/common"
"github.com/certusone/wormhole/node/pkg/readiness"
"github.com/certusone/wormhole/node/pkg/supervisor"
"github.com/gorilla/websocket"
"github.com/tidwall/gjson"
"github.com/wormhole-foundation/wormhole/sdk/vaa"
"go.uber.org/zap"
)
type (
// Watcher is responsible for looking over a cosmwasm blockchain and reporting new transactions to the contract
Watcher struct {
urlWS string
urlLCD string
contract string
msgChan chan *common.MessagePublication
// Incoming re-observation requests from the network. Pre-filtered to only
// include requests for our chainID.
obsvReqC chan *gossipv1.ObservationRequest
// Readiness component
readiness readiness.Component
// VAA ChainID of the network we're connecting to.
chainID vaa.ChainID
// Key for contract address in the wasm logs
contractAddressFilterKey string
// Key for contract address in the wasm logs
contractAddressLogKey string
// URL to get the latest block info from
latestBlockURL string
}
)
var (
connectionErrors = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "wormhole_terra_connection_errors_total",
Help: "Total number of connection errors on a cosmwasm chain",
}, []string{"terra_network", "reason"})
messagesConfirmed = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "wormhole_terra_messages_confirmed_total",
Help: "Total number of verified messages found on a cosmwasm chain",
}, []string{"terra_network"})
currentSlotHeight = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "wormhole_terra_current_height",
Help: "Current slot height on a cosmwasm chain (at default commitment level, not the level used for observations)",
}, []string{"terra_network"})
queryLatency = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "wormhole_terra_query_latency",
Help: "Latency histogram for RPC calls on a cosmwasm chain",
}, []string{"terra_network", "operation"})
)
type clientRequest struct {
JSONRPC string `json:"jsonrpc"`
// A String containing the name of the method to be invoked.
Method string `json:"method"`
// Object to pass as request parameter to the method.
Params [1]string `json:"params"`
// The request id. This can be of any type. It is used to match the
// response with the request that it is replying to.
ID uint64 `json:"id"`
}
// NewWatcher creates a new cosmwasm contract watcher
func NewWatcher(
urlWS string,
urlLCD string,
contract string,
lockEvents chan *common.MessagePublication,
obsvReqC chan *gossipv1.ObservationRequest,
readiness readiness.Component,
chainID vaa.ChainID) *Watcher {
// CosmWasm 1.0.0
contractAddressFilterKey := "execute._contract_address"
contractAddressLogKey := "_contract_address"
if chainID == vaa.ChainIDTerra {
// CosmWasm <1.0.0
contractAddressFilterKey = "execute_contract.contract_address"
contractAddressLogKey = "contract_address"
}
// Do not add a leading slash
latestBlockURL := "blocks/latest"
// Injective does things slightly differently than terra
if chainID == vaa.ChainIDInjective {
latestBlockURL = "cosmos/base/tendermint/v1beta1/blocks/latest"
}
return &Watcher{
urlWS: urlWS,
urlLCD: urlLCD,
contract: contract,
msgChan: lockEvents,
obsvReqC: obsvReqC,
readiness: readiness,
chainID: chainID,
contractAddressFilterKey: contractAddressFilterKey,
contractAddressLogKey: contractAddressLogKey,
latestBlockURL: latestBlockURL,
}
}
func (e *Watcher) Run(ctx context.Context) error {
networkName := vaa.ChainID(e.chainID).String()
p2p.DefaultRegistry.SetNetworkStats(e.chainID, &gossipv1.Heartbeat_Network{
ContractAddress: e.contract,
})
errC := make(chan error)
logger := supervisor.Logger(ctx)
logger.Info("connecting to websocket", zap.String("network", networkName), zap.String("url", e.urlWS))
c, _, err := websocket.DefaultDialer.DialContext(ctx, e.urlWS, nil)
if err != nil {
p2p.DefaultRegistry.AddErrorCount(e.chainID, 1)
connectionErrors.WithLabelValues(networkName, "websocket_dial_error").Inc()
return fmt.Errorf("websocket dial failed: %w", err)
}
defer c.Close()
// Subscribe to smart contract transactions
params := [...]string{fmt.Sprintf("tm.event='Tx' AND %s='%s'", e.contractAddressFilterKey, e.contract)}
command := &clientRequest{
JSONRPC: "2.0",
Method: "subscribe",
Params: params,
ID: 1,
}
err = c.WriteJSON(command)
if err != nil {
p2p.DefaultRegistry.AddErrorCount(e.chainID, 1)
connectionErrors.WithLabelValues(networkName, "websocket_subscription_error").Inc()
return fmt.Errorf("websocket subscription failed: %w", err)
}
// Wait for the success response
_, _, err = c.ReadMessage()
if err != nil {
p2p.DefaultRegistry.AddErrorCount(e.chainID, 1)
connectionErrors.WithLabelValues(networkName, "event_subscription_error").Inc()
return fmt.Errorf("event subscription failed: %w", err)
}
logger.Info("subscribed to new transaction events", zap.String("network", networkName))
readiness.SetReady(e.readiness)
go func() {
t := time.NewTicker(5 * time.Second)
client := &http.Client{
Timeout: time.Second * 5,
}
for {
<-t.C
msm := time.Now()
// Query and report height and set currentSlotHeight
resp, err := client.Get(fmt.Sprintf("%s/%s", e.urlLCD, e.latestBlockURL))
if err != nil {
logger.Error("query latest block response error", zap.String("network", networkName), zap.Error(err))
continue
}
blocksBody, err := io.ReadAll(resp.Body)
if err != nil {
logger.Error("query latest block response read error", zap.String("network", networkName), zap.Error(err))
errC <- err
resp.Body.Close()
continue
}
resp.Body.Close()
// Update the prom metrics with how long the http request took to the rpc
queryLatency.WithLabelValues(networkName, "block_latest").Observe(time.Since(msm).Seconds())
blockJSON := string(blocksBody)
latestBlock := gjson.Get(blockJSON, "block.header.height")
logger.Info("current height", zap.String("network", networkName), zap.Int64("block", latestBlock.Int()))
currentSlotHeight.WithLabelValues(networkName).Set(float64(latestBlock.Int()))
p2p.DefaultRegistry.SetNetworkStats(e.chainID, &gossipv1.Heartbeat_Network{
Height: latestBlock.Int(),
ContractAddress: e.contract,
})
}
}()
go func() {
for {
select {
case <-ctx.Done():
return
case r := <-e.obsvReqC:
if vaa.ChainID(r.ChainId) != e.chainID {
panic("invalid chain ID")
}
tx := hex.EncodeToString(r.TxHash)
logger.Info("received observation request", zap.String("network", networkName), zap.String("tx_hash", tx))
client := &http.Client{
Timeout: time.Second * 5,
}
// Query for tx by hash
resp, err := client.Get(fmt.Sprintf("%s/cosmos/tx/v1beta1/txs/%s", e.urlLCD, tx))
if err != nil {
logger.Error("query tx response error", zap.String("network", networkName), zap.Error(err))
continue
}
txBody, err := io.ReadAll(resp.Body)
if err != nil {
logger.Error("query tx response read error", zap.String("network", networkName), zap.Error(err))
resp.Body.Close()
continue
}
resp.Body.Close()
txJSON := string(txBody)
txHashRaw := gjson.Get(txJSON, "tx_response.txhash")
if !txHashRaw.Exists() {
logger.Error("tx does not have tx hash", zap.String("network", networkName), zap.String("payload", txJSON))
continue
}
txHash := txHashRaw.String()
events := gjson.Get(txJSON, "tx_response.events")
if !events.Exists() {
logger.Error("tx has no events", zap.String("network", networkName), zap.String("payload", txJSON))
continue
}
msgs := EventsToMessagePublications(e.contract, txHash, events.Array(), logger, e.chainID, e.contractAddressLogKey)
for _, msg := range msgs {
e.msgChan <- msg
messagesConfirmed.WithLabelValues(networkName).Inc()
}
}
}
}()
go func() {
defer close(errC)
for {
_, message, err := c.ReadMessage()
if err != nil {
p2p.DefaultRegistry.AddErrorCount(e.chainID, 1)
connectionErrors.WithLabelValues(networkName, "channel_read_error").Inc()
logger.Error("error reading channel", zap.String("network", networkName), zap.Error(err))
errC <- err
return
}
// Received a message from the blockchain
json := string(message)
txHashRaw := gjson.Get(json, "result.events.tx\\.hash.0")
if !txHashRaw.Exists() {
logger.Warn("message does not have tx hash", zap.String("network", networkName), zap.String("payload", json))
continue
}
txHash := txHashRaw.String()
events := gjson.Get(json, "result.data.value.TxResult.result.events")
if !events.Exists() {
logger.Warn("message has no events", zap.String("network", networkName), zap.String("payload", json))
continue
}
msgs := EventsToMessagePublications(e.contract, txHash, events.Array(), logger, e.chainID, e.contractAddressLogKey)
for _, msg := range msgs {
e.msgChan <- msg
messagesConfirmed.WithLabelValues(networkName).Inc()
}
// We do not send guardian changes to the processor - ETH guardians are the source of truth.
}
}()
select {
case <-ctx.Done():
err := c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
if err != nil {
logger.Error("error on closing socket ", zap.String("network", networkName), zap.Error(err))
}
return ctx.Err()
case err := <-errC:
return err
}
}
func EventsToMessagePublications(contract string, txHash string, events []gjson.Result, logger *zap.Logger, chainID vaa.ChainID, contractAddressKey string) []*common.MessagePublication {
networkName := vaa.ChainID(chainID).String()
msgs := make([]*common.MessagePublication, 0, len(events))
for _, event := range events {
if !event.IsObject() {
logger.Warn("event is invalid", zap.String("network", networkName), zap.String("tx_hash", txHash), zap.String("event", event.String()))
continue
}
eventType := gjson.Get(event.String(), "type")
if eventType.String() != "wasm" {
continue
}
attributes := gjson.Get(event.String(), "attributes")
if !attributes.Exists() {
logger.Warn("message event has no attributes", zap.String("network", networkName), zap.String("tx_hash", txHash), zap.String("event", event.String()))
continue
}
mappedAttributes := map[string]string{}
for _, attribute := range attributes.Array() {
if !attribute.IsObject() {
logger.Warn("event attribute is invalid", zap.String("network", networkName), zap.String("tx_hash", txHash), zap.String("attribute", attribute.String()))
continue
}
keyBase := gjson.Get(attribute.String(), "key")
if !keyBase.Exists() {
logger.Warn("event attribute does not have key", zap.String("network", networkName), zap.String("tx_hash", txHash), zap.String("attribute", attribute.String()))
continue
}
valueBase := gjson.Get(attribute.String(), "value")
if !valueBase.Exists() {
logger.Warn("event attribute does not have value", zap.String("network", networkName), zap.String("tx_hash", txHash), zap.String("attribute", attribute.String()))
continue
}
key, err := base64.StdEncoding.DecodeString(keyBase.String())
if err != nil {
logger.Warn("event key attribute is invalid", zap.String("network", networkName), zap.String("tx_hash", txHash), zap.String("key", keyBase.String()))
continue
}
value, err := base64.StdEncoding.DecodeString(valueBase.String())
if err != nil {
logger.Warn("event value attribute is invalid", zap.String("network", networkName), zap.String("tx_hash", txHash), zap.String("key", keyBase.String()), zap.String("value", valueBase.String()))
continue
}
if _, ok := mappedAttributes[string(key)]; ok {
logger.Debug("duplicate key in events", zap.String("network", networkName), zap.String("tx_hash", txHash), zap.String("key", keyBase.String()), zap.String("value", valueBase.String()))
continue
}
mappedAttributes[string(key)] = string(value)
}
contractAddress, ok := mappedAttributes[contractAddressKey]
if !ok {
logger.Warn("wasm event without contract address field set", zap.String("network", networkName), zap.String("event", event.String()))
continue
}
// This is not a wormhole message
if contractAddress != contract {
continue
}
payload, ok := mappedAttributes["message.message"]
if !ok {
logger.Error("wormhole event does not have a message field", zap.String("network", networkName), zap.String("tx_hash", txHash), zap.String("attributes", attributes.String()))
continue
}
sender, ok := mappedAttributes["message.sender"]
if !ok {
logger.Error("wormhole event does not have a sender field", zap.String("network", networkName), zap.String("tx_hash", txHash), zap.String("attributes", attributes.String()))
continue
}
chainId, ok := mappedAttributes["message.chain_id"]
if !ok {
logger.Error("wormhole event does not have a chain_id field", zap.String("network", networkName), zap.String("tx_hash", txHash), zap.String("attributes", attributes.String()))
continue
}
nonce, ok := mappedAttributes["message.nonce"]
if !ok {
logger.Error("wormhole event does not have a nonce field", zap.String("network", networkName), zap.String("tx_hash", txHash), zap.String("attributes", attributes.String()))
continue
}
sequence, ok := mappedAttributes["message.sequence"]
if !ok {
logger.Error("wormhole event does not have a sequence field", zap.String("network", networkName), zap.String("tx_hash", txHash), zap.String("attributes", attributes.String()))
continue
}
blockTime, ok := mappedAttributes["message.block_time"]
if !ok {
logger.Error("wormhole event does not have a block_time field", zap.String("network", networkName), zap.String("tx_hash", txHash), zap.String("attributes", attributes.String()))
continue
}
logger.Info("new message detected on cosmwasm",
zap.String("network", networkName),
zap.String("chainId", chainId),
zap.String("txHash", txHash),
zap.String("sender", sender),
zap.String("nonce", nonce),
zap.String("sequence", sequence),
zap.String("blockTime", blockTime),
)
senderAddress, err := StringToAddress(sender)
if err != nil {
logger.Error("cannot decode emitter hex", zap.String("network", networkName), zap.String("tx_hash", txHash), zap.String("value", sender))
continue
}
txHashValue, err := StringToHash(txHash)
if err != nil {
logger.Error("cannot decode tx hash hex", zap.String("network", networkName), zap.String("tx_hash", txHash), zap.String("value", txHash))
continue
}
payloadValue, err := hex.DecodeString(payload)
if err != nil {
logger.Error("cannot decode payload", zap.String("network", networkName), zap.String("tx_hash", txHash), zap.String("value", payload))
continue
}
blockTimeInt, err := strconv.ParseInt(blockTime, 10, 64)
if err != nil {
logger.Error("blocktime cannot be parsed as int", zap.String("network", networkName), zap.String("tx_hash", txHash), zap.String("value", blockTime))
continue
}
nonceInt, err := strconv.ParseUint(nonce, 10, 32)
if err != nil {
logger.Error("nonce cannot be parsed as int", zap.String("network", networkName), zap.String("tx_hash", txHash), zap.String("value", blockTime))
continue
}
sequenceInt, err := strconv.ParseUint(sequence, 10, 64)
if err != nil {
logger.Error("sequence cannot be parsed as int", zap.String("network", networkName), zap.String("tx_hash", txHash), zap.String("value", blockTime))
continue
}
messagePublication := &common.MessagePublication{
TxHash: txHashValue,
Timestamp: time.Unix(blockTimeInt, 0),
Nonce: uint32(nonceInt),
Sequence: sequenceInt,
EmitterChain: chainID,
EmitterAddress: senderAddress,
Payload: payloadValue,
ConsistencyLevel: 0, // Instant finality
}
msgs = append(msgs, messagePublication)
}
return msgs
}
// StringToAddress convert string into address
func StringToAddress(value string) (vaa.Address, error) {
var address vaa.Address
res, err := hex.DecodeString(value)
if err != nil {
return address, err
}
copy(address[:], res)
return address, nil
}
// StringToHash convert string into transaction hash
func StringToHash(value string) (eth_common.Hash, error) {
var hash eth_common.Hash
res, err := hex.DecodeString(value)
if err != nil {
return hash, err
}
copy(hash[:], res)
return hash, nil
}