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

784 lines
26 KiB
Go

package ibc
import (
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/certusone/wormhole/node/pkg/common"
"github.com/certusone/wormhole/node/pkg/p2p"
"github.com/certusone/wormhole/node/pkg/readiness"
"github.com/certusone/wormhole/node/pkg/supervisor"
"github.com/certusone/wormhole/node/pkg/watchers/cosmwasm"
"github.com/wormhole-foundation/wormhole/sdk/vaa"
gossipv1 "github.com/certusone/wormhole/node/pkg/proto/gossip/v1"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/tidwall/gjson"
"nhooyr.io/websocket"
"nhooyr.io/websocket/wsjson"
ethCommon "github.com/ethereum/go-ethereum/common"
"go.uber.org/zap"
)
type (
// ChainConfig is the list of chains to be monitored over IBC, along with their channel data.
ChainConfig []ChainConfigEntry
// ChainConfigEntry defines the entry for a chain being monitored by IBC.
ChainConfigEntry struct {
ChainID vaa.ChainID
MsgC chan<- *common.MessagePublication
ObsvReqC <-chan *gossipv1.ObservationRequest
}
)
// GetFeatures returns the IBC feature string that can be published in heartbeat messages.
func GetFeatures() string {
featuresMutex.Lock()
defer featuresMutex.Unlock()
return features
}
// setFeatures is used internally to set the IBC feature flag.
func setFeatures(f string) {
featuresMutex.Lock()
defer featuresMutex.Unlock()
features = f
}
var (
// Chains defines the list of chains to be monitored by IBC. Add new chains here as necessary.
Chains = []vaa.ChainID{
vaa.ChainIDOsmosis,
vaa.ChainIDSei,
vaa.ChainIDCosmoshub,
vaa.ChainIDEvmos,
vaa.ChainIDKujira,
vaa.ChainIDNeutron,
vaa.ChainIDCelestia,
}
// features is the feature string to be published in the gossip heartbeat messages. It will include all chains that are actually enabled on IBC.
features = ""
// featuresMutex is used to protect the features flag.
featuresMutex sync.Mutex
ibcErrors = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "ibc_errors_by_reason",
Help: "Total number of errors on IBC",
}, []string{"reason"})
messagesConfirmed = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "wormhole_ibc_messages_confirmed_total",
Help: "Total number of verified messages found on an IBC connected chain",
}, []string{"chain_name"})
currentSlotHeight = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "wormhole_ibc_current_height",
Help: "Current slot height on an IBC connected chain (the block height on wormchain)",
}, []string{"chain_name"})
invalidChainIdMismatches = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "wormhole_ibc_chain_id_mismatches",
Help: "Total number of cases where the wormhole chain ID does not match the IBC connection ID",
}, []string{"ibc_channel_id"})
)
type (
// Watcher is responsible for monitoring the IBC contract on wormchain and publishing wormhole messages for all chains connected via IBC.
Watcher struct {
wsUrl string
lcdUrl string
blockHeightUrl string
contractAddress string
logger *zap.Logger
// chainMap defines the data associated with all connected / enabled chains.
chainMap map[vaa.ChainID]*chainEntry
// channelIdToChainIdMap provides a mapping from IBC channel ID to chain ID. Note that there can be multiple channels IDs for the same chain.
channelIdToChainIdMap map[string]vaa.ChainID
// channelIdToChainIdLock protects channelIdToChainIdMap.
channelIdToChainIdLock sync.Mutex
// baseFeatures is used to create the feature string. It is the list of chains enabled, without the wormhole version.
baseFeatures string
}
// chainEntry defines the data associated with a chain.
chainEntry struct {
chainID vaa.ChainID
chainName string
readiness readiness.Component
msgC chan<- *common.MessagePublication
obsvReqC <-chan *gossipv1.ObservationRequest
}
)
// NewWatcher creates a new IBC contract watcher
func NewWatcher(
wsUrl string,
lcdUrl string,
blockHeightUrl string,
contractAddress string,
chainConfig ChainConfig,
) *Watcher {
feats := ""
chainMap := make(map[vaa.ChainID]*chainEntry)
for _, chainToMonitor := range chainConfig {
_, exists := chainMap[chainToMonitor.ChainID]
if exists {
panic(fmt.Sprintf("detected duplicate chainID: %v", chainToMonitor.ChainID))
}
ce := &chainEntry{
chainID: chainToMonitor.ChainID,
chainName: chainToMonitor.ChainID.String(),
readiness: common.MustConvertChainIdToReadinessSyncing(chainToMonitor.ChainID),
msgC: chainToMonitor.MsgC,
obsvReqC: chainToMonitor.ObsvReqC,
}
chainMap[ce.chainID] = ce
if feats == "" {
feats = "ibc:"
} else {
feats += "|"
}
feats += ce.chainID.String()
}
setFeatures(feats)
return &Watcher{
wsUrl: wsUrl,
lcdUrl: lcdUrl,
blockHeightUrl: blockHeightUrl,
contractAddress: contractAddress,
chainMap: chainMap,
channelIdToChainIdMap: make(map[string]vaa.ChainID),
baseFeatures: feats,
}
}
// clientRequest is used to subscribe for events from the contract.
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"`
}
// ibcReceivePublishEvent represents the log message received from the IBC receiver contract.
type ibcReceivePublishEvent struct {
ChannelID string
Msg *common.MessagePublication
}
// abciInfoResults is used to parse the abci_info query response to get the wormhole version and block height.
type abciInfoResults struct {
Result struct {
Response struct {
Version string `json:"version"`
LastBlockHeight string `json:"last_block_height"`
}
}
}
// Run is the runnable for monitoring the IBC contract on wormchain.
func (w *Watcher) Run(ctx context.Context) error {
w.logger = supervisor.Logger(ctx)
w.logger.Info("Starting watcher",
zap.String("watcher_name", "ibc"),
zap.String("wsUrl", w.wsUrl),
zap.String("lcdUrl", w.lcdUrl),
zap.String("blockHeightUrl", w.blockHeightUrl),
zap.String("contractAddress", w.contractAddress),
)
errC := make(chan error)
blockHeightUrl := w.blockHeightUrl
if blockHeightUrl == "" {
blockHeightUrl = convertWsUrlToHttpUrl(w.wsUrl)
}
blockHeightUrl = blockHeightUrl + "/abci_info"
w.logger.Info("creating watcher",
zap.String("wsUrl", w.wsUrl),
zap.String("lcdUrl", w.lcdUrl),
zap.String("blockHeightUrl", blockHeightUrl),
zap.String("contract", w.contractAddress),
zap.String("features", GetFeatures()),
)
for _, ce := range w.chainMap {
w.logger.Info("will monitor chain over IBC", zap.String("chain", ce.chainName))
p2p.DefaultRegistry.SetNetworkStats(ce.chainID, &gossipv1.Heartbeat_Network{ContractAddress: w.contractAddress})
}
c, _, err := websocket.Dial(ctx, w.wsUrl, nil)
if err != nil {
ibcErrors.WithLabelValues("websocket_dial_error").Inc()
return fmt.Errorf("failed to establish tendermint websocket connection: %w", err)
}
defer c.Close(websocket.StatusNormalClosure, "")
c.SetReadLimit(cosmwasm.ReadLimitSize)
// Subscribe to smart contract transactions.
params := [...]string{fmt.Sprintf("tm.event='Tx' AND wasm._contract_address='%s'", w.contractAddress)}
command := &clientRequest{
JSONRPC: "2.0",
Method: "subscribe",
Params: params,
ID: 1,
}
err = wsjson.Write(ctx, c, command)
if err != nil {
ibcErrors.WithLabelValues("websocket_subscription_error").Inc()
return fmt.Errorf("failed to subscribe to events: %w", err)
}
// Wait for the success response.
_, subResp, err := c.Read(ctx)
if err != nil {
ibcErrors.WithLabelValues("websocket_subscription_error").Inc()
return fmt.Errorf("failed to receive response to subscribe request: %w", err)
}
if strings.Contains(string(subResp), "error") {
ibcErrors.WithLabelValues("websocket_subscription_error").Inc()
return fmt.Errorf("failed to subscribe to events, response: %s", string(subResp))
}
// Start a routine to listen for messages from the contract.
common.RunWithScissors(ctx, errC, "ibc_data_pump", func(ctx context.Context) error {
return w.handleEvents(ctx, c)
})
// Start a routine to periodically query the wormchain block height.
common.RunWithScissors(ctx, errC, "ibc_block_height", func(ctx context.Context) error {
return w.handleQueryBlockHeight(ctx, blockHeightUrl)
})
// Start a routine for each chain to listen for observation requests.
for _, ce := range w.chainMap {
ce := ce // Avoid data race.
common.RunWithScissors(ctx, errC, "ibc_objs_req", func(ctx context.Context) error {
return w.handleObservationRequests(ctx, ce)
})
}
// Signal to the supervisor that this runnable has finished initialization.
supervisor.Signal(ctx, supervisor.SignalHealthy)
select {
case <-ctx.Done():
return ctx.Err()
case err := <-errC:
return err
}
}
// handleEvents reads messages from the IBC receiver contract and processes them.
func (w *Watcher) handleEvents(ctx context.Context, c *websocket.Conn) error {
for {
select {
case <-ctx.Done():
return nil
default:
_, message, err := c.Read(ctx)
if err != nil {
w.logger.Error("failed to read socket", zap.Error(err))
ibcErrors.WithLabelValues("channel_read_error").Inc()
return fmt.Errorf("failed to read socket: %w", err)
}
// Received a message from the blockchain.
json := string(message)
txHashRaw := gjson.Get(json, "result.events.tx\\.hash.0")
if !txHashRaw.Exists() {
w.logger.Warn("message does not have tx hash", zap.String("payload", json))
continue
}
txHash, err := vaa.StringToHash(txHashRaw.String())
if err != nil {
w.logger.Warn("failed to parse txHash", zap.String("txHash", txHashRaw.String()), zap.Error(err))
continue
}
events := gjson.Get(json, "result.data.value.TxResult.result.events")
if !events.Exists() {
w.logger.Warn("message has no events", zap.String("payload", json))
continue
}
for _, event := range events.Array() {
if !event.IsObject() {
w.logger.Warn("event is invalid", zap.Stringer("tx_hash", txHash), zap.String("event", event.String()))
continue
}
eventType := gjson.Get(event.String(), "type").String()
if eventType == "wasm" {
evt, err := parseIbcReceivePublishEvent(w.logger, w.contractAddress, event, txHash)
if err != nil {
w.logger.Error("failed to parse wasm event", zap.Error(err), zap.String("event", event.String()))
continue
}
if evt != nil {
if err := w.processIbcReceivePublishEvent(evt, "new"); err != nil {
return fmt.Errorf("failed to process new IBC event: %w", err)
}
}
} else {
w.logger.Debug("ignoring uninteresting event", zap.String("eventType", eventType))
}
}
}
}
}
// convertWsUrlToHttpUrl takes a string like "ws://wormchain:26657/websocket" and converts it to "http://wormchain:26657". This is
// used to query for the abci_info. That query doesn't work on the LCD. We have to do it on the websocket port, using an http URL.
func convertWsUrlToHttpUrl(url string) string {
url = strings.TrimPrefix(url, "ws://")
url = strings.TrimPrefix(url, "wss://")
url = strings.TrimSuffix(url, "/websocket")
return "http://" + url
}
// handleQueryBlockHeight gets the latest block height from wormchain each interval and updates the status on all the connected chains.
func (w *Watcher) handleQueryBlockHeight(ctx context.Context, queryUrl string) error {
t := time.NewTicker(5 * time.Second)
client := &http.Client{
Timeout: time.Second * 5,
}
for {
select {
case <-ctx.Done():
return nil
case <-t.C:
resp, err := client.Get(queryUrl) //nolint:noctx // TODO FIXME we should propagate context with Deadline here.
if err != nil {
return fmt.Errorf("failed to query latest block: %w", err)
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return fmt.Errorf("failed to read latest block body: %w", err)
}
var abciInfo abciInfoResults
err = json.Unmarshal(body, &abciInfo)
if err != nil {
w.logger.Error("failed to unmarshal abci info", zap.String("json", string(body)), zap.Error(err))
continue
}
blockHeight, err := strconv.ParseInt(abciInfo.Result.Response.LastBlockHeight, 10, 64)
if err != nil {
w.logger.Error("failed to parse block height", zap.String("blockHeight", abciInfo.Result.Response.LastBlockHeight), zap.Error(err))
continue
}
w.logger.Debug("current block height", zap.Int64("height", blockHeight), zap.String("wormchainVersion", abciInfo.Result.Response.Version))
for _, ce := range w.chainMap {
currentSlotHeight.WithLabelValues(ce.chainName).Set(float64(blockHeight))
p2p.DefaultRegistry.SetNetworkStats(ce.chainID, &gossipv1.Heartbeat_Network{
Height: blockHeight,
ContractAddress: w.contractAddress,
})
readiness.SetReady(ce.readiness)
}
readiness.SetReady(common.ReadinessIBCSyncing)
setFeatures(w.baseFeatures + ":" + abciInfo.Result.Response.Version)
}
}
}
// handleObservationRequests listens for observation requests for a single chain and processes them by reading the requested transaction
// from wormchain and publishing the associated message. This function is instantiated for each connected chain.
func (w *Watcher) handleObservationRequests(ctx context.Context, ce *chainEntry) error {
for {
select {
case <-ctx.Done():
return nil
case r := <-ce.obsvReqC:
if vaa.ChainID(r.ChainId) != ce.chainID {
panic("invalid chain ID")
}
reqTxHashStr := hex.EncodeToString(r.TxHash)
w.logger.Info("received observation request", zap.String("chain", ce.chainName), zap.String("txHash", reqTxHashStr))
client := &http.Client{
Timeout: time.Second * 5,
}
// Query for tx by hash.
resp, err := client.Get(fmt.Sprintf("%s/cosmos/tx/v1beta1/txs/%s", w.lcdUrl, reqTxHashStr)) //nolint:noctx // TODO FIXME we should propagate context with Deadline here.
if err != nil {
w.logger.Error("query tx response error", zap.String("chain", ce.chainName), zap.Error(err))
continue
}
txBody, err := io.ReadAll(resp.Body)
if err != nil {
w.logger.Error("query tx response read error", zap.String("chain", ce.chainName), zap.Error(err))
resp.Body.Close()
continue
}
resp.Body.Close()
txJSON := string(txBody)
txHashRaw := gjson.Get(txJSON, "tx_response.txhash")
if !txHashRaw.Exists() {
w.logger.Error("tx does not have tx hash", zap.String("chain", ce.chainName), zap.String("payload", txJSON))
continue
}
txHashStr := txHashRaw.String()
txHash, err := vaa.StringToHash(txHashStr)
if err != nil {
w.logger.Error("tx does not have tx hash", zap.String("chain", ce.chainName), zap.String("payload", txJSON))
continue
}
events := gjson.Get(txJSON, "tx_response.events")
if !events.Exists() {
w.logger.Error("tx has no events", zap.String("chain", ce.chainName), zap.String("payload", txJSON))
continue
}
for _, event := range events.Array() {
if !event.IsObject() {
w.logger.Error("event is invalid", zap.String("chain", ce.chainName), zap.String("tx_hash", txHashStr), zap.String("event", event.String()))
continue
}
eventType := gjson.Get(event.String(), "type")
if eventType.String() == "wasm" {
w.logger.Debug("found wasm event in reobservation", zap.String("chain", ce.chainName), zap.Stringer("txHash", txHash))
evt, err := parseIbcReceivePublishEvent(w.logger, w.contractAddress, event, txHash)
if err != nil {
w.logger.Error("failed to parse wasm event", zap.String("chain", ce.chainName), zap.Error(err), zap.Any("event", event))
continue
}
if evt != nil {
evt.Msg.IsReobservation = true
if err := w.processIbcReceivePublishEvent(evt, "reobservation"); err != nil {
return fmt.Errorf("failed to process reobserved IBC event: %w", err)
}
}
} else {
w.logger.Debug("ignoring uninteresting event in reobservation", zap.String("chain", ce.chainName), zap.Stringer("txHash", txHash), zap.String("eventType", eventType.String()))
}
}
}
}
}
// parseIbcReceivePublishEvent parses a wasm event into an object. Since the watcher only subscribes to events from a single contract, this function returns an error
// if the contract does not match the desired one. However, since the contract publishes multiple action types, this function returns nil rather than an error
// if the event is not for the desired action.
func parseIbcReceivePublishEvent(logger *zap.Logger, desiredContract string, event gjson.Result, txHash ethCommon.Hash) (*ibcReceivePublishEvent, error) {
var attributes WasmAttributes
err := attributes.Parse(logger, event)
if err != nil {
logger.Error("failed to parse event attributes", zap.Error(err), zap.String("event", event.String()))
return nil, fmt.Errorf("failed to parse attributes: %w", err)
}
str, err := attributes.GetAsString("_contract_address")
if err != nil {
return nil, err
}
if str != desiredContract {
return nil, fmt.Errorf("received an event from an unexpected contract: %s", str)
}
str, err = attributes.GetAsString("action")
if err != nil || str != "receive_publish" {
return nil, nil
}
evt := new(ibcReceivePublishEvent)
evt.Msg = new(common.MessagePublication)
evt.Msg.TxHash = txHash
evt.ChannelID, err = attributes.GetAsString("channel_id")
if err != nil {
return evt, err
}
unumber, err := attributes.GetAsUint("message.chain_id", 16)
if err != nil {
return evt, err
}
evt.Msg.EmitterChain = vaa.ChainID(unumber)
str, err = attributes.GetAsString("message.sender")
if err != nil {
return evt, err
}
evt.Msg.EmitterAddress, err = vaa.StringToAddress(str)
if err != nil {
return evt, fmt.Errorf("failed to parse message.sender attribute %s: %w", str, err)
}
unumber, err = attributes.GetAsUint("message.nonce", 32)
if err != nil {
return evt, err
}
evt.Msg.Nonce = uint32(unumber)
unumber, err = attributes.GetAsUint("message.sequence", 64)
if err != nil {
return evt, err
}
evt.Msg.Sequence = unumber
snumber, err := attributes.GetAsInt("message.block_time", 64)
if err != nil {
return evt, err
}
evt.Msg.Timestamp = time.Unix(snumber, 0)
str, err = attributes.GetAsString("message.message")
if err != nil {
return evt, err
}
evt.Msg.Payload, err = hex.DecodeString(str)
if err != nil {
return evt, fmt.Errorf("failed to parse message.message attribute %s: %w", str, err)
}
return evt, nil
}
// processIbcReceivePublishEvent takes an IBC event, maps it to a message publication and publishes it.
func (w *Watcher) processIbcReceivePublishEvent(evt *ibcReceivePublishEvent, observationType string) error {
mappedChainID, err := w.getChainIdFromChannelID(evt.ChannelID)
if err != nil {
w.logger.Error("query for IBC channel ID failed",
zap.String("IbcChannelID", evt.ChannelID),
zap.Stringer("TxHash", evt.Msg.TxHash),
zap.Stringer("EmitterChain", evt.Msg.EmitterChain),
zap.Stringer("EmitterAddress", evt.Msg.EmitterAddress),
zap.Uint64("Sequence", evt.Msg.Sequence),
zap.Uint32("Nonce", evt.Msg.Nonce),
zap.Stringer("Timestamp", evt.Msg.Timestamp),
zap.Uint8("ConsistencyLevel", evt.Msg.ConsistencyLevel),
zap.Error(err),
)
ibcErrors.WithLabelValues("query_error").Inc()
return fmt.Errorf("failed to query IBC channel ID mapping: %w", err)
}
if mappedChainID == vaa.ChainIDUnset {
// This can happen if the channel ID to chain ID mapping in the contract hasn't been updated yet (pending governance).
// Therefore we don't want to return an error here. Restarting won't help.
w.logger.Error(fmt.Sprintf("received %s message from unknown IBC channel, dropping observation", observationType),
zap.String("IbcChannelID", evt.ChannelID),
zap.Stringer("TxHash", evt.Msg.TxHash),
zap.Stringer("EmitterChain", evt.Msg.EmitterChain),
zap.Stringer("EmitterAddress", evt.Msg.EmitterAddress),
zap.Uint64("Sequence", evt.Msg.Sequence),
zap.Uint32("Nonce", evt.Msg.Nonce),
zap.Stringer("Timestamp", evt.Msg.Timestamp),
zap.Uint8("ConsistencyLevel", evt.Msg.ConsistencyLevel),
)
ibcErrors.WithLabelValues("unexpected_ibc_channel_error").Inc()
return nil
}
ce, exists := w.chainMap[mappedChainID]
if !exists {
// This is not an error because some guardians may choose to run the full node and not listen to this chain over IBC.
w.logger.Debug(fmt.Sprintf("received %s message from an unconfigured chain, dropping observation", observationType),
zap.String("IbcChannelID", evt.ChannelID),
zap.Stringer("ChainID", mappedChainID),
zap.Stringer("TxHash", evt.Msg.TxHash),
zap.Stringer("EmitterChain", evt.Msg.EmitterChain),
zap.Stringer("EmitterAddress", evt.Msg.EmitterAddress),
zap.Uint64("Sequence", evt.Msg.Sequence),
zap.Uint32("Nonce", evt.Msg.Nonce),
zap.Stringer("Timestamp", evt.Msg.Timestamp),
zap.Uint8("ConsistencyLevel", evt.Msg.ConsistencyLevel),
)
return nil
}
if evt.Msg.EmitterChain != ce.chainID {
w.logger.Error(fmt.Sprintf("chain id mismatch in %s message", observationType),
zap.String("IbcChannelID", evt.ChannelID),
zap.Uint16("MappedChainID", uint16(mappedChainID)),
zap.Uint16("ExpectedChainID", uint16(ce.chainID)),
zap.Stringer("TxHash", evt.Msg.TxHash),
zap.Stringer("EmitterChain", evt.Msg.EmitterChain),
zap.Stringer("EmitterAddress", evt.Msg.EmitterAddress),
zap.Uint64("Sequence", evt.Msg.Sequence),
zap.Uint32("Nonce", evt.Msg.Nonce),
zap.Stringer("Timestamp", evt.Msg.Timestamp),
zap.Uint8("ConsistencyLevel", evt.Msg.ConsistencyLevel),
)
invalidChainIdMismatches.WithLabelValues(evt.ChannelID).Inc()
return nil // Don't return an error here because we don't want an external source to be able to kill the watcher.
}
w.logger.Info(fmt.Sprintf("%s message detected", observationType),
zap.String("IbcChannelID", evt.ChannelID),
zap.String("ChainName", ce.chainName),
zap.Stringer("TxHash", evt.Msg.TxHash),
zap.Stringer("EmitterChain", evt.Msg.EmitterChain),
zap.Stringer("EmitterAddress", evt.Msg.EmitterAddress),
zap.Uint64("Sequence", evt.Msg.Sequence),
zap.Uint32("Nonce", evt.Msg.Nonce),
zap.Stringer("Timestamp", evt.Msg.Timestamp),
zap.Uint8("ConsistencyLevel", evt.Msg.ConsistencyLevel),
)
ce.msgC <- evt.Msg
messagesConfirmed.WithLabelValues(ce.chainName).Inc()
return nil
}
// getChainIdFromChannelID returns the chain ID associated with the specified IBC channel. It uses a cache to avoid constantly querying
// wormchain. This works because once an IBC channel is closed its ID will never be reused. This also means that there could be multiple
// IBC channels for the same chain ID.
// See the IBC spec for details: https://github.com/cosmos/ibc/tree/main/spec/core/ics-004-channel-and-packet-semantics#closing-handshake
func (w *Watcher) getChainIdFromChannelID(channelID string) (vaa.ChainID, error) {
w.channelIdToChainIdLock.Lock()
defer w.channelIdToChainIdLock.Unlock()
chainID, exists := w.channelIdToChainIdMap[channelID]
if exists {
return chainID, nil
}
// We continue to hold the lock here because we don't want two routines (event handler and reobservation) both querying at the same time.
channelIdToChainIdMap, err := w.queryChannelIdToChainIdMapping()
if err != nil {
w.logger.Error("failed to query channelID to chainID mapping", zap.Error(err))
return vaa.ChainIDUnset, err
}
w.channelIdToChainIdMap = channelIdToChainIdMap
chainID, exists = w.channelIdToChainIdMap[channelID]
if exists {
return chainID, nil
}
return vaa.ChainIDUnset, nil
}
/*
This query:
'{"all_channel_chains": {}}' is `eyJhbGxfY2hhbm5lbF9jaGFpbnMiOiB7fX0=`
which becomes:
http://localhost:1319/cosmwasm/wasm/v1/contract/wormhole1nc5tatafv6eyq7llkr2gv50ff9e22mnf70qgjlv737ktmt4eswrq0kdhcj/smart/eyJhbGxfY2hhbm5lbF9jaGFpbnMiOiB7fX0%3D
Returns something like this:
{
"data": {
"channels_chains": [
[
"Y2hhbm5lbC0w",
18
]
]
}
}
*/
type ibcAllChannelChainsQueryResults struct {
Data struct {
ChannelChains [][]interface{} `json:"channels_chains"`
}
}
var allChannelChainsQuery = url.QueryEscape(base64.StdEncoding.EncodeToString([]byte(`{"all_channel_chains": {}}`)))
// queryChannelIdToChainIdMapping queries the contract for the set of IBC channels and their correspond chain IDs.
func (w *Watcher) queryChannelIdToChainIdMapping() (map[string]vaa.ChainID, error) {
client := &http.Client{
Timeout: time.Second * 5,
}
query := fmt.Sprintf(`%s/cosmwasm/wasm/v1/contract/%s/smart/%s`, w.lcdUrl, w.contractAddress, allChannelChainsQuery)
resp, err := client.Get(query) //nolint:noctx // TODO FIXME we should propagate context with Deadline here.
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read failed: %w", err)
}
resp.Body.Close()
var result ibcAllChannelChainsQueryResults
err = json.Unmarshal(body, &result)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %s, error: %w", string(body), err)
}
if len(result.Data.ChannelChains) == 0 {
return nil, fmt.Errorf("query did not return any data")
}
w.logger.Info("queried IBC channel ID mapping", zap.Int("numEntriesReturned", len(result.Data.ChannelChains)))
ret := make(map[string]vaa.ChainID)
for idx, entry := range result.Data.ChannelChains {
if len(entry) != 2 {
return nil, fmt.Errorf("channel map entry %d contains %d items when it should contain exactly two, json: %s", idx, len(entry), string(body))
}
channelIdBytes, err := base64.StdEncoding.DecodeString(entry[0].(string))
if err != nil {
return nil, fmt.Errorf("channel ID for entry %d is invalid base64: %s, err: %s", idx, entry[0], err)
}
channelID := string(channelIdBytes)
chainIdFloat, ok := entry[1].(float64)
if !ok {
return nil, fmt.Errorf("error converting channelId to float64")
}
chainID := vaa.ChainID(chainIdFloat)
ret[channelID] = chainID
w.logger.Info("IBC channel ID mapping", zap.String("channelID", channelID), zap.Uint16("chainID", uint16(chainID)))
}
return ret, nil
}