diff --git a/.gitignore b/.gitignore index 0a14d388b..d39e39ff1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Don't commit the interchaintest.test file, # regardless of where it was built. interchaintest.test - -/bin \ No newline at end of file +.idea +/bin +vendor diff --git a/cmd/interchaintest/example_matrix.json b/cmd/interchaintest/example_matrix.json index 6ecae2e45..d5dbe96c9 100644 --- a/cmd/interchaintest/example_matrix.json +++ b/cmd/interchaintest/example_matrix.json @@ -1,5 +1,5 @@ { - "Relayers": ["rly"], + "Relayers": ["rly", "hermes"], "ChainSets": [ [ diff --git a/cmd/interchaintest/interchaintest_test.go b/cmd/interchaintest/interchaintest_test.go index 4be2e24db..16a2ee13e 100644 --- a/cmd/interchaintest/interchaintest_test.go +++ b/cmd/interchaintest/interchaintest_test.go @@ -103,7 +103,7 @@ func setUpTestMatrix() error { if extraFlags.MatrixFile == "" { fmt.Fprintln(os.Stderr, "No matrix file provided, falling back to rly with gaia and osmosis") - testMatrix.Relayers = []string{"rly"} + testMatrix.Relayers = []string{"rly", "hermes"} testMatrix.ChainSets = [][]*interchaintest.ChainSpec{ { {Name: "gaia", Version: "v7.0.1"}, diff --git a/conformance/flush.go b/conformance/flush.go index 1aaf21d2e..29b48fb07 100644 --- a/conformance/flush.go +++ b/conformance/flush.go @@ -9,6 +9,7 @@ import ( interchaintest "github.com/strangelove-ventures/interchaintest/v6" "github.com/strangelove-ventures/interchaintest/v6/ibc" "github.com/strangelove-ventures/interchaintest/v6/relayer" + "github.com/strangelove-ventures/interchaintest/v6/relayer/hermes" "github.com/strangelove-ventures/interchaintest/v6/testreporter" "github.com/strangelove-ventures/interchaintest/v6/testutil" "github.com/stretchr/testify/require" @@ -100,9 +101,14 @@ func TestRelayerFlushing(t *testing.T, ctx context.Context, cf interchaintest.Ch afterFlushHeight, err := c0.Height(ctx) req.NoError(err) + //flush packets and flush acks are the same command in hermes and are not separated as in the go relayer // Ack shouldn't happen yet. _, err = testutil.PollForAck(ctx, c0, beforeTransferHeight, afterFlushHeight+2, tx.Packet) - req.ErrorIs(err, testutil.ErrNotFound) + if _, isHermes := r.(*hermes.Relayer); isHermes { + req.NoError(err) + } else { + req.ErrorIs(err, testutil.ErrNotFound) + } }) t.Run("flush acks", func(t *testing.T) { diff --git a/conformance/relayersetup.go b/conformance/relayersetup.go index 3cd306a2f..6e1300134 100644 --- a/conformance/relayersetup.go +++ b/conformance/relayersetup.go @@ -5,13 +5,14 @@ import ( "fmt" "testing" - conntypes "github.com/cosmos/ibc-go/v6/modules/core/03-connection/types" interchaintest "github.com/strangelove-ventures/interchaintest/v6" "github.com/strangelove-ventures/interchaintest/v6/ibc" "github.com/strangelove-ventures/interchaintest/v6/testreporter" "github.com/strangelove-ventures/interchaintest/v6/testutil" "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" + + conntypes "github.com/cosmos/ibc-go/v6/modules/core/03-connection/types" ) // TestRelayerSetup contains a series of subtests that configure a relayer step-by-step. @@ -102,7 +103,7 @@ func TestRelayerSetup(t *testing.T, ctx context.Context, cf interchaintest.Chain conn0 := conns0[0] req.NotEmpty(conn0.ID) req.NotEmpty(conn0.ClientID) - req.Equal(conn0.State, conntypes.OPEN.String()) + req.Subset([]string{conntypes.OPEN.String(), "Open"}, []string{conn0.State}) conns1, err := r.GetConnections(ctx, eRep, c1.Config().ChainID) req.NoError(err) @@ -111,7 +112,7 @@ func TestRelayerSetup(t *testing.T, ctx context.Context, cf interchaintest.Chain conn1 := conns1[0] req.NotEmpty(conn1.ID) req.NotEmpty(conn1.ClientID) - req.Equal(conn1.State, conntypes.OPEN.String()) + req.Subset([]string{conntypes.OPEN.String(), "Open"}, []string{conn1.State}) // Now validate counterparties. req.Equal(conn0.Counterparty.ClientId, conn1.ClientID) @@ -160,14 +161,14 @@ func TestRelayerSetup(t *testing.T, ctx context.Context, cf interchaintest.Chain // Piecemeal assertions against each channel. // Not asserting against ConnectionHops or ChannelID. - req.Equal(ch0.State, "STATE_OPEN") - req.Equal(ch0.Ordering, "ORDER_UNORDERED") + req.Subset([]string{"STATE_OPEN", "Open"}, []string{ch0.State}) + req.Subset([]string{"ORDER_UNORDERED", "Unordered"}, []string{ch0.Ordering}) req.Equal(ch0.Counterparty, ibc.ChannelCounterparty{PortID: "transfer", ChannelID: ch1.ChannelID}) req.Equal(ch0.Version, "ics20-1") req.Equal(ch0.PortID, "transfer") - req.Equal(ch1.State, "STATE_OPEN") - req.Equal(ch1.Ordering, "ORDER_UNORDERED") + req.Subset([]string{"STATE_OPEN", "Open"}, []string{ch1.State}) + req.Subset([]string{"ORDER_UNORDERED", "Unordered"}, []string{ch1.Ordering}) req.Equal(ch1.Counterparty, ibc.ChannelCounterparty{PortID: "transfer", ChannelID: ch0.ChannelID}) req.Equal(ch1.Version, "ics20-1") req.Equal(ch1.PortID, "transfer") diff --git a/conformance/test.go b/conformance/test.go index 5b0f794c8..216df3b7e 100644 --- a/conformance/test.go +++ b/conformance/test.go @@ -35,7 +35,6 @@ import ( "testing" "time" - transfertypes "github.com/cosmos/ibc-go/v6/modules/apps/transfer/types" "github.com/docker/docker/client" interchaintest "github.com/strangelove-ventures/interchaintest/v6" "github.com/strangelove-ventures/interchaintest/v6/chain/cosmos" @@ -47,6 +46,8 @@ import ( "github.com/strangelove-ventures/interchaintest/v6/testutil" "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" + + transfertypes "github.com/cosmos/ibc-go/v6/modules/apps/transfer/types" ) const ( @@ -328,6 +329,7 @@ func TestChainPair( } if relayerImpl == nil { + t.Logf("creating relayer: %s", rf.Name()) // startup both chains. // creates wallets in the relayer for src and dst chain. // funds relayer src and dst wallets on respective chain in genesis. diff --git a/examples/ibc/interchain_accounts_test.go b/examples/ibc/interchain_accounts_test.go index 26f05ca8f..c341ba0de 100644 --- a/examples/ibc/interchain_accounts_test.go +++ b/examples/ibc/interchain_accounts_test.go @@ -268,12 +268,12 @@ func TestInterchainAccounts(t *testing.T) { chain1Chans, err := r.GetChannels(ctx, eRep, chain1.Config().ChainID) require.NoError(t, err) require.Equal(t, 1, len(chain1Chans)) - require.Equal(t, "STATE_CLOSED", chain1Chans[0].State) + require.Subset(t, []string{"STATE_CLOSED", "Closed"}, []string{chain1Chans[0].State}) chain2Chans, err := r.GetChannels(ctx, eRep, chain2.Config().ChainID) require.NoError(t, err) require.Equal(t, 1, len(chain2Chans)) - require.Equal(t, "STATE_CLOSED", chain2Chans[0].State) + require.Subset(t, []string{"STATE_CLOSED", "Closed"}, []string{chain2Chans[0].State}) // Attempt to open another channel for the same ICA _, _, err = chain1.Exec(ctx, registerICA, nil) @@ -294,12 +294,12 @@ func TestInterchainAccounts(t *testing.T) { chain1Chans, err = r.GetChannels(ctx, eRep, chain1.Config().ChainID) require.NoError(t, err) require.Equal(t, 2, len(chain1Chans)) - require.Equal(t, "STATE_OPEN", chain1Chans[1].State) + require.Subset(t, []string{"STATE_OPEN", "Open"}, []string{chain1Chans[1].State}) chain2Chans, err = r.GetChannels(ctx, eRep, chain2.Config().ChainID) require.NoError(t, err) require.Equal(t, 2, len(chain2Chans)) - require.Equal(t, "STATE_OPEN", chain2Chans[1].State) + require.Subset(t, []string{"STATE_OPEN", "Open"}, []string{chain2Chans[1].State}) } // parseInterchainAccountField takes a slice of bytes which should be returned when querying for an ICA via diff --git a/interchain_test.go b/interchain_test.go index 28720037a..74b6f1300 100644 --- a/interchain_test.go +++ b/interchain_test.go @@ -26,7 +26,15 @@ import ( clienttypes "github.com/cosmos/ibc-go/v6/modules/core/02-client/types" ) -func TestInterchain_DuplicateChain(t *testing.T) { +func TestInterchain_DuplicateChain_CosmosRly(t *testing.T) { + duplicateChainTest(t, ibc.CosmosRly) +} + +func TestInterchain_DuplicateChain_HermesRelayer(t *testing.T) { + duplicateChainTest(t, ibc.Hermes) +} + +func duplicateChainTest(t *testing.T, relayerImpl ibc.RelayerImplementation) { if testing.Short() { t.Skip("skipping in short mode") } @@ -46,7 +54,7 @@ func TestInterchain_DuplicateChain(t *testing.T) { gaia0, gaia1 := chains[0], chains[1] - r := interchaintest.NewBuiltinRelayerFactory(ibc.CosmosRly, zaptest.NewLogger(t)).Build( + r := interchaintest.NewBuiltinRelayerFactory(relayerImpl, zaptest.NewLogger(t)).Build( t, client, network, ) @@ -74,7 +82,15 @@ func TestInterchain_DuplicateChain(t *testing.T) { _ = ic.Close() } -func TestInterchain_GetRelayerWallets(t *testing.T) { +func TestInterchain_GetRelayerWallets_CosmosRly(t *testing.T) { + getRelayerWalletsTest(t, ibc.CosmosRly) +} + +func TestInterchain_GetRelayerWallets_HermesRelayer(t *testing.T) { + getRelayerWalletsTest(t, ibc.Hermes) +} + +func getRelayerWalletsTest(t *testing.T, relayerImpl ibc.RelayerImplementation) { if testing.Short() { t.Skip("skipping in short mode") } @@ -94,7 +110,7 @@ func TestInterchain_GetRelayerWallets(t *testing.T) { gaia0, gaia1 := chains[0], chains[1] - r := interchaintest.NewBuiltinRelayerFactory(ibc.CosmosRly, zaptest.NewLogger(t)).Build( + r := interchaintest.NewBuiltinRelayerFactory(relayerImpl, zaptest.NewLogger(t)).Build( t, client, network, ) @@ -230,7 +246,15 @@ func TestInterchain_CreateUser(t *testing.T) { }) } -func TestCosmosChain_BroadcastTx(t *testing.T) { +func TestCosmosChain_BroadcastTx_CosmosRly(t *testing.T) { + broadcastTxCosmosChainTest(t, ibc.CosmosRly) +} + +func TestCosmosChain_BroadcastTx_HermesRelayer(t *testing.T) { + broadcastTxCosmosChainTest(t, ibc.Hermes) +} + +func broadcastTxCosmosChainTest(t *testing.T, relayerImpl ibc.RelayerImplementation) { if testing.Short() { t.Skip("skipping in short mode") } @@ -250,7 +274,7 @@ func TestCosmosChain_BroadcastTx(t *testing.T) { gaia0, gaia1 := chains[0], chains[1] - r := interchaintest.NewBuiltinRelayerFactory(ibc.CosmosRly, zaptest.NewLogger(t)).Build( + r := interchaintest.NewBuiltinRelayerFactory(relayerImpl, zaptest.NewLogger(t)).Build( t, client, network, ) diff --git a/internal/dockerutil/filewriter.go b/internal/dockerutil/filewriter.go index 44bde3f4b..7b66ca2f2 100644 --- a/internal/dockerutil/filewriter.go +++ b/internal/dockerutil/filewriter.go @@ -5,7 +5,6 @@ import ( "bytes" "context" "fmt" - "path" "time" "github.com/docker/docker/api/types" @@ -48,10 +47,10 @@ func (w *FileWriter) WriteFile(ctx context.Context, volumeName, relPath string, Cmd: []string{ // Take the uid and gid of the mount path, // and set that as the owner of the new relative path. - `chown "$(stat -c '%u:%g' "$1")" "$2"`, + `chown -R "$(stat -c '%u:%g' "$1")" "$2"`, "_", // Meaningless arg0 for sh -c with positional args. mountPath, - path.Join(mountPath, relPath), + mountPath, }, // Use root user to avoid permission issues when reading files from the volume. diff --git a/relayer/docker.go b/relayer/docker.go index 5217d71b2..c528f6f3d 100644 --- a/relayer/docker.go +++ b/relayer/docker.go @@ -20,6 +20,10 @@ import ( "go.uber.org/zap" ) +const ( + defaultRlyHomeDirectory = "/home/relayer" +) + // DockerRelayer provides a common base for relayer implementations // that run on Docker. type DockerRelayer struct { @@ -42,6 +46,8 @@ type DockerRelayer struct { // wallets contains a mapping of chainID to relayer wallet wallets map[string]ibc.Wallet + + homeDir string } var _ ibc.Relayer = (*DockerRelayer)(nil) @@ -64,12 +70,16 @@ func NewDockerRelayer(ctx context.Context, log *zap.Logger, testName string, cli wallets: map[string]ibc.Wallet{}, } + r.homeDir = defaultRlyHomeDirectory + for _, opt := range options { switch o := opt.(type) { case RelayerOptionDockerImage: r.customImage = &o.DockerImage case RelayerOptionImagePull: r.pullImage = o.Pull + case RelayerOptionHomeDir: + r.homeDir = o.HomeDir } } @@ -122,6 +132,21 @@ func NewDockerRelayer(ctx context.Context, log *zap.Logger, testName string, cli return &r, nil } +// WriteFileToHomeDir writes the given contents to a file at the relative path specified. The file is relative +// to the home directory in the relayer container. +func (r *DockerRelayer) WriteFileToHomeDir(ctx context.Context, relativePath string, contents []byte) error { + fw := dockerutil.NewFileWriter(r.log, r.client, r.testName) + if err := fw.WriteFile(ctx, r.volumeName, relativePath, contents); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + return nil +} + +// AddWallet adds a stores a wallet for the given chain ID. +func (r *DockerRelayer) AddWallet(chainID string, wallet ibc.Wallet) { + r.wallets[chainID] = wallet +} + func (r *DockerRelayer) AddChainConfiguration(ctx context.Context, rep ibc.RelayerExecReporter, chainConfig ibc.ChainConfig, keyName, rpcAddr, grpcAddr string) error { // For rly this file is json, but the file extension should not matter. // Using .config to avoid implying any particular format. @@ -469,7 +494,7 @@ func (r *DockerRelayer) Bind() []string { // HomeDir returns the home directory of the relayer on the underlying Docker container's filesystem. func (r *DockerRelayer) HomeDir() string { - return "/home/relayer" + return r.homeDir } func (r *DockerRelayer) HostName(pathName string) string { diff --git a/relayer/hermes/hermes_commander.go b/relayer/hermes/hermes_commander.go new file mode 100644 index 000000000..38e3b95b9 --- /dev/null +++ b/relayer/hermes/hermes_commander.go @@ -0,0 +1,208 @@ +package hermes + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/strangelove-ventures/interchaintest/v6/ibc" + "github.com/strangelove-ventures/interchaintest/v6/relayer" + "go.uber.org/zap" + + ibcexported "github.com/cosmos/ibc-go/v6/modules/core/03-connection/types" + "github.com/cosmos/ibc-go/v6/modules/core/23-commitment/types" +) + +var _ relayer.RelayerCommander = &commander{} + +type commander struct { + log *zap.Logger +} + +func (c commander) Name() string { + return hermes +} + +func (c commander) DefaultContainerImage() string { + return defaultContainerImage +} + +func (c commander) DefaultContainerVersion() string { + return DefaultContainerVersion +} + +func (c commander) DockerUser() string { + return hermesDefaultUidGid +} + +func (c commander) ParseGetChannelsOutput(stdout, stderr string) ([]ibc.ChannelOutput, error) { + jsonBz := extractJsonResult([]byte(stdout)) + var result ChannelOutputResult + if err := json.Unmarshal(jsonBz, &result); err != nil { + return nil, err + } + + var ibcChannelOutput []ibc.ChannelOutput + for _, r := range result.Result { + ibcChannelOutput = append(ibcChannelOutput, ibc.ChannelOutput{ + State: r.ChannelEnd.State, + Ordering: r.ChannelEnd.Ordering, + Counterparty: ibc.ChannelCounterparty{ + PortID: r.CounterPartyChannelEnd.Remote.PortID, + ChannelID: r.CounterPartyChannelEnd.Remote.ChannelID, + }, + ConnectionHops: r.ChannelEnd.ConnectionHops, + Version: r.ChannelEnd.Version, + PortID: r.ChannelEnd.Remote.PortID, + ChannelID: r.ChannelEnd.Remote.ChannelID, + }) + } + + return ibcChannelOutput, nil +} + +func (c commander) ParseGetConnectionsOutput(stdout, stderr string) (ibc.ConnectionOutputs, error) { + jsonBz := extractJsonResult([]byte(stdout)) + var queryResult ConnectionQueryResult + if err := json.Unmarshal(jsonBz, &queryResult); err != nil { + return ibc.ConnectionOutputs{}, err + } + + var outputs ibc.ConnectionOutputs + for _, r := range queryResult.Result { + + var versions []*ibcexported.Version + for _, v := range r.ConnectionEnd.Versions { + versions = append(versions, &ibcexported.Version{ + Identifier: v.Identifier, + Features: v.Features, + }) + } + + outputs = append(outputs, &ibc.ConnectionOutput{ + ID: r.ConnectionID, + ClientID: r.ConnectionEnd.ClientID, + Versions: versions, + State: r.ConnectionEnd.State, + Counterparty: &ibcexported.Counterparty{ + ClientId: r.ConnectionEnd.Counterparty.ClientID, + ConnectionId: r.ConnectionEnd.Counterparty.ConnectionID, + Prefix: types.MerklePrefix{ + KeyPrefix: []byte(r.ConnectionEnd.Counterparty.Prefix), + }, + }, + }) + } + return outputs, nil +} + +func (c commander) ParseGetClientsOutput(stdout, stderr string) (ibc.ClientOutputs, error) { + jsonBz := extractJsonResult([]byte(stdout)) + var queryResult ClientQueryResult + if err := json.Unmarshal(jsonBz, &queryResult); err != nil { + return ibc.ClientOutputs{}, err + } + + var clientOutputs []*ibc.ClientOutput + for _, r := range queryResult.ClientResult { + clientOutputs = append(clientOutputs, &ibc.ClientOutput{ + ClientID: r.ClientID, + ClientState: ibc.ClientState{ + ChainID: r.ChainID, + }, + }) + } + + return clientOutputs, nil +} + +func (c commander) Init(homeDir string) []string { + return nil +} + +func (c commander) GetChannels(chainID, homeDir string) []string { + // the --verbose and --show-counterparty options are required to get enough information to correctly populate + // the path. + return []string{hermes, "--json", "query", "channels", "--chain", chainID, "--show-counterparty", "--verbose"} +} + +func (c commander) GetConnections(chainID, homeDir string) []string { + return []string{hermes, "--config", fmt.Sprintf("%s/%s", homeDir, hermesConfigPath), "--json", "query", "connections", "--chain", chainID, "--verbose"} +} + +func (c commander) GetClients(chainID, homeDir string) []string { + return []string{hermes, "--config", fmt.Sprintf("%s/%s", homeDir, hermesConfigPath), "--json", "query", "clients", "--host-chain", chainID} +} + +func (c commander) StartRelayer(homeDir string, pathNames ...string) []string { + return []string{hermes, "--config", fmt.Sprintf("%s/%s", homeDir, hermesConfigPath), "start", "--full-scan"} +} + +func (c commander) CreateWallet(keyName, address, mnemonic string) ibc.Wallet { + return NewWallet(keyName, address, mnemonic) +} + +func (c commander) UpdatePath(pathName, homeDir string, filter ibc.ChannelFilter) []string { + // TODO: figure out how to implement this. + panic("implement me") +} + +// the following methods do not have a single command that cleanly maps to a single hermes command without +// additional logic wrapping them. They have been implemented one layer up in the hermes relayer. + +func (c commander) UpdateClients(pathName, homeDir string) []string { + panic("update clients implemented in hermes relayer not the commander") +} + +func (c commander) GeneratePath(srcChainID, dstChainID, pathName, homeDir string) []string { + panic("generate path implemented in hermes relayer not the commander") +} + +func (c commander) LinkPath(pathName, homeDir string, channelOpts ibc.CreateChannelOptions, clientOpts ibc.CreateClientOptions) []string { + panic("link path implemented in hermes relayer not the commander") +} + +func (c commander) RestoreKey(chainID, keyName, coinType, mnemonic, homeDir string) []string { + panic("restore key implemented in hermes relayer not the commander") +} + +func (c commander) AddChainConfiguration(containerFilePath, homeDir string) []string { + panic("add chain configuration implemented in hermes relayer not the commander") +} + +func (c commander) AddKey(chainID, keyName, coinType, homeDir string) []string { + panic("add key implemented in hermes relayer not the commander") +} + +func (c commander) CreateChannel(pathName string, opts ibc.CreateChannelOptions, homeDir string) []string { + panic("create channel implemented in hermes relayer not the commander") +} + +func (c commander) CreateClients(pathName string, opts ibc.CreateClientOptions, homeDir string) []string { + panic("create clients implemented in hermes relayer not the commander") +} + +func (c commander) CreateConnections(pathName string, homeDir string) []string { + panic("create connections implemented in hermes relayer not the commander") +} + +func (c commander) FlushAcknowledgements(pathName, channelID, homeDir string) []string { + panic("flush acks implemented in hermes relayer not the commander") +} + +func (c commander) FlushPackets(pathName, channelID, homeDir string) []string { + panic("flush packets implemented in hermes relayer not the commander") +} + +func (c commander) ConfigContent(ctx context.Context, cfg ibc.ChainConfig, keyName, rpcAddr, grpcAddr string) ([]byte, error) { + panic("config content implemented in hermes relayer not the commander") +} + +func (c commander) ParseAddKeyOutput(stdout, stderr string) (ibc.Wallet, error) { + panic("add key implemented in Hermes Relayer") +} + +// ParseRestoreKeyOutput extracts the address from the hermes output. +func (c commander) ParseRestoreKeyOutput(stdout, stderr string) string { + panic("implemented in Hermes Relayer") +} diff --git a/relayer/hermes/hermes_config.go b/relayer/hermes/hermes_config.go new file mode 100644 index 000000000..50e1b9695 --- /dev/null +++ b/relayer/hermes/hermes_config.go @@ -0,0 +1,175 @@ +package hermes + +import ( + "fmt" + "strconv" + "strings" +) + + +// NewConfig returns a hermes Config with an entry for each of the provided ChainConfigs. +// The defaults were adapted from the sample config file found here: https://github.com/informalsystems/hermes/blob/master/config.toml +func NewConfig(chainConfigs ...ChainConfig) Config { + var chains []Chain + for _, hermesCfg := range chainConfigs { + chainCfg := hermesCfg.cfg + + gasPricesStr, err := strconv.ParseFloat(strings.ReplaceAll(chainCfg.GasPrices, chainCfg.Denom, ""), 32) + if err != nil { + panic(err) + } + + chains = append(chains, Chain{ + ID: chainCfg.ChainID, + RPCAddr: hermesCfg.rpcAddr, + GrpcAddr: fmt.Sprintf("http://%s", hermesCfg.grpcAddr), + WebsocketAddr: strings.ReplaceAll(fmt.Sprintf("%s/websocket", hermesCfg.rpcAddr), "http", "ws"), + RPCTimeout: "10s", + AccountPrefix: chainCfg.Bech32Prefix, + KeyName: hermesCfg.keyName, + AddressType: AddressType{ + Derivation: "cosmos", + }, + StorePrefix: "ibc", + DefaultGas: 100000, + MaxGas: 400000, + GasPrice: GasPrice{ + Price: gasPricesStr, + Denom: chainCfg.Denom, + }, + GasMultiplier: chainCfg.GasAdjustment, + MaxMsgNum: 30, + MaxTxSize: 2097152, + ClockDrift: "5s", + MaxBlockTime: "30s", + TrustingPeriod: "14days", + TrustThreshold: TrustThreshold{ + Numerator: "1", + Denominator: "3", + }, + MemoPrefix: "hermes", + }, + ) + } + + return Config{ + Global: Global{ + LogLevel: "info", + }, + Mode: Mode{ + Clients: Clients{ + Enabled: true, + Refresh: true, + Misbehaviour: true, + }, + Connections: Connections{ + Enabled: true, + }, + Channels: Channels{ + Enabled: true, + }, + Packets: Packets{ + Enabled: true, + ClearInterval: 0, + ClearOnStart: true, + TxConfirmation: false, + }, + }, + Rest: Rest{ + Enabled: false, + }, + Telemetry: Telemetry{ + Enabled: false, + }, + Chains: chains, + } +} + +type Config struct { + Global Global `toml:"global"` + Mode Mode `toml:"mode"` + Rest Rest `toml:"rest"` + Telemetry Telemetry `toml:"telemetry"` + Chains []Chain `toml:"chains"` +} + +type Global struct { + LogLevel string `toml:"log_level"` +} + +type Clients struct { + Enabled bool `toml:"enabled"` + Refresh bool `toml:"refresh"` + Misbehaviour bool `toml:"misbehaviour"` +} + +type Connections struct { + Enabled bool `toml:"enabled"` +} + +type Channels struct { + Enabled bool `toml:"enabled"` +} + +type Packets struct { + Enabled bool `toml:"enabled"` + ClearInterval int `toml:"clear_interval"` + ClearOnStart bool `toml:"clear_on_start"` + TxConfirmation bool `toml:"tx_confirmation"` +} + +type Mode struct { + Clients Clients `toml:"clients"` + Connections Connections `toml:"connections"` + Channels Channels `toml:"channels"` + Packets Packets `toml:"packets"` +} + +type Rest struct { + Enabled bool `toml:"enabled"` + Host string `toml:"host"` + Port int `toml:"port"` +} + +type Telemetry struct { + Enabled bool `toml:"enabled"` + Host string `toml:"host"` + Port int `toml:"port"` +} + +type AddressType struct { + Derivation string `toml:"derivation"` +} + +type GasPrice struct { + Price float64 `toml:"price"` + Denom string `toml:"denom"` +} + +type TrustThreshold struct { + Numerator string `toml:"numerator"` + Denominator string `toml:"denominator"` +} + +type Chain struct { + ID string `toml:"id"` + RPCAddr string `toml:"rpc_addr"` + GrpcAddr string `toml:"grpc_addr"` + WebsocketAddr string `toml:"websocket_addr"` + RPCTimeout string `toml:"rpc_timeout"` + AccountPrefix string `toml:"account_prefix"` + KeyName string `toml:"key_name"` + AddressType AddressType `toml:"address_type"` + StorePrefix string `toml:"store_prefix"` + DefaultGas int `toml:"default_gas"` + MaxGas int `toml:"max_gas"` + GasPrice GasPrice `toml:"gas_price"` + GasMultiplier float64 `toml:"gas_multiplier"` + MaxMsgNum int `toml:"max_msg_num"` + MaxTxSize int `toml:"max_tx_size"` + ClockDrift string `toml:"clock_drift"` + MaxBlockTime string `toml:"max_block_time"` + TrustingPeriod string `toml:"trusting_period"` + TrustThreshold TrustThreshold `toml:"trust_threshold"` + MemoPrefix string `toml:"memo_prefix,omitempty"` +} diff --git a/relayer/hermes/hermes_relayer.go b/relayer/hermes/hermes_relayer.go new file mode 100644 index 000000000..4525ee4b6 --- /dev/null +++ b/relayer/hermes/hermes_relayer.go @@ -0,0 +1,307 @@ +package hermes + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + "time" + + "github.com/docker/docker/client" + "github.com/pelletier/go-toml" + "github.com/strangelove-ventures/interchaintest/v6/ibc" + "github.com/strangelove-ventures/interchaintest/v6/relayer" + "go.uber.org/zap" +) + +const ( + hermes = "hermes" + defaultContainerImage = "docker.io/informalsystems/hermes" + DefaultContainerVersion = "1.2.0" + + hermesDefaultUidGid = "1000:1000" + hermesHome = "/home/hermes" + hermesConfigPath = ".hermes/config.toml" +) + +var ( + _ ibc.Relayer = &Relayer{} + // parseRestoreKeyOutputPattern extracts the address from the hermes output. + // SUCCESS Restored key 'g2-2' (cosmos1czklnpzwaq3hfxtv6ne4vas2p9m5q3p3fgkz8e) on chain g2-2 + parseRestoreKeyOutputPattern = regexp.MustCompile(`\((.*)\)`) +) + +// Relayer is the ibc.Relayer implementation for hermes. +type Relayer struct { + *relayer.DockerRelayer + paths map[string]*pathConfiguration + chainConfigs []ChainConfig +} + +// ChainConfig holds all values required to write an entry in the "chains" section in the hermes config file. +type ChainConfig struct { + cfg ibc.ChainConfig + keyName, rpcAddr, grpcAddr string +} + +// pathConfiguration represents the concept of a "path" which is implemented at the interchain test level rather +// than the hermes level. +type pathConfiguration struct { + chainA, chainB pathChainConfig +} + +// pathChainConfig holds all values that will be required when interacting with a path. +type pathChainConfig struct { + chainID string + clientID string + connectionID string + portID string +} + +// NewHermesRelayer returns a new hermes relayer. +func NewHermesRelayer(log *zap.Logger, testName string, cli *client.Client, networkID string, options ...relayer.RelayerOption) *Relayer { + c := commander{log: log} + options = append(options, relayer.HomeDir(hermesHome)) + dr, err := relayer.NewDockerRelayer(context.TODO(), log, testName, cli, networkID, c, options...) + if err != nil { + panic(err) + } + + return &Relayer{ + DockerRelayer: dr, + } +} + +// AddChainConfiguration is called once per chain configuration, which means that in the case of hermes, the single +// config file is overwritten with a new entry each time this function is called. +func (r *Relayer) AddChainConfiguration(ctx context.Context, rep ibc.RelayerExecReporter, chainConfig ibc.ChainConfig, keyName, rpcAddr, grpcAddr string) error { + configContent, err := r.configContent(chainConfig, keyName, rpcAddr, grpcAddr) + if err != nil { + return fmt.Errorf("failed to generate config content: %w", err) + } + + if err := r.WriteFileToHomeDir(ctx, hermesConfigPath, configContent); err != nil { + return fmt.Errorf("failed to write hermes config: %w", err) + } + + return r.validateConfig(ctx, rep) +} + +// LinkPath performs the operations that happen when a path is linked. This includes creating clients, creating connections +// and establishing a channel. This happens across multiple operations rather than a single link path cli command. +func (r *Relayer) LinkPath(ctx context.Context, rep ibc.RelayerExecReporter, pathName string, channelOpts ibc.CreateChannelOptions, clientOpts ibc.CreateClientOptions) error { + _, ok := r.paths[pathName] + if !ok { + return fmt.Errorf("path %s not found", pathName) + } + + if err := r.CreateClients(ctx, rep, pathName, clientOpts); err != nil { + return err + } + + if err := r.CreateConnections(ctx, rep, pathName); err != nil { + return err + } + + if err := r.CreateChannel(ctx, rep, pathName, channelOpts); err != nil { + return err + } + + return nil +} + +func (r *Relayer) CreateChannel(ctx context.Context, rep ibc.RelayerExecReporter, pathName string, opts ibc.CreateChannelOptions) error { + pathConfig := r.paths[pathName] + cmd := []string{hermes, "--json", "create", "channel", "--a-chain", pathConfig.chainA.chainID, "--a-port", opts.SourcePortName, "--b-port", opts.DestPortName, "--a-connection", pathConfig.chainA.connectionID} + res := r.Exec(ctx, rep, cmd, nil) + if res.Err != nil { + return res.Err + } + pathConfig.chainA.portID = opts.SourcePortName + pathConfig.chainB.portID = opts.DestPortName + return nil +} + +func (r *Relayer) CreateConnections(ctx context.Context, rep ibc.RelayerExecReporter, pathName string) error { + pathConfig := r.paths[pathName] + cmd := []string{hermes, "--json", "create", "connection", "--a-chain", pathConfig.chainA.chainID, "--a-client", pathConfig.chainA.clientID, "--b-client", pathConfig.chainB.clientID} + + res := r.Exec(ctx, rep, cmd, nil) + if res.Err != nil { + return res.Err + } + + chainAConnectionID, chainBConnectionID, err := getConnectionIDsFromStdout(res.Stdout) + if err != nil { + return err + } + pathConfig.chainA.connectionID = chainAConnectionID + pathConfig.chainB.connectionID = chainBConnectionID + return res.Err +} + +func (r *Relayer) UpdateClients(ctx context.Context, rep ibc.RelayerExecReporter, pathName string) error { + pathConfig, ok := r.paths[pathName] + if !ok { + return fmt.Errorf("path %s not found", pathName) + } + updateChainACmd := []string{hermes, "--json", "update", "client", "--host-chain", pathConfig.chainA.chainID, "--client", pathConfig.chainA.clientID} + res := r.Exec(ctx, rep, updateChainACmd, nil) + if res.Err != nil { + return res.Err + } + updateChainBCmd := []string{hermes, "--json", "update", "client", "--host-chain", pathConfig.chainB.chainID, "--client", pathConfig.chainB.clientID} + return r.Exec(ctx, rep, updateChainBCmd, nil).Err +} + +// CreateClients creates clients on both chains. +// Note: in the go relayer this can be done with a single command using the path reference, +// however in Hermes this needs to be done as two separate commands. +func (r *Relayer) CreateClients(ctx context.Context, rep ibc.RelayerExecReporter, pathName string, opts ibc.CreateClientOptions) error { + pathConfig := r.paths[pathName] + chainACreateClientCmd := []string{hermes, "--json", "create", "client", "--host-chain", pathConfig.chainA.chainID, "--reference-chain", pathConfig.chainB.chainID} + res := r.Exec(ctx, rep, chainACreateClientCmd, nil) + if res.Err != nil { + return res.Err + } + + chainAClientId, err := getClientIdFromStdout(res.Stdout) + if err != nil { + return err + } + pathConfig.chainA.clientID = chainAClientId + + chainBCreateClientCmd := []string{hermes, "--json", "create", "client", "--host-chain", pathConfig.chainB.chainID, "--reference-chain", pathConfig.chainA.chainID} + res = r.Exec(ctx, rep, chainBCreateClientCmd, nil) + if res.Err != nil { + return res.Err + } + + chainBClientId, err := getClientIdFromStdout(res.Stdout) + if err != nil { + return err + } + pathConfig.chainB.clientID = chainBClientId + + return res.Err +} + +// RestoreKey restores a key from a mnemonic. In hermes, you must provide a file containing the mnemonic. We need +// to copy the contents of the mnemonic into a file on disk and then reference the newly created file. +func (r *Relayer) RestoreKey(ctx context.Context, rep ibc.RelayerExecReporter, chainID, keyName, coinType, mnemonic string) error { + + relativeMnemonicFilePath := fmt.Sprintf("%s/mnemonic.txt", chainID) + if err := r.WriteFileToHomeDir(ctx, relativeMnemonicFilePath, []byte(mnemonic)); err != nil { + return fmt.Errorf("failed to write mnemonic file: %w", err) + } + + cmd := []string{hermes, "keys", "add", "--chain", chainID, "--mnemonic-file", fmt.Sprintf("%s/%s", r.HomeDir(), relativeMnemonicFilePath), "--key-name", keyName} + + // Restoring a key should be near-instantaneous, so add a 1-minute timeout + // to detect if Docker has hung. + ctx, cancel := context.WithTimeout(ctx, time.Minute) + defer cancel() + + res := r.Exec(ctx, rep, cmd, nil) + if res.Err != nil { + return res.Err + } + + addrBytes := parseRestoreKeyOutput(string(res.Stdout)) + r.AddWallet(chainID, NewWallet(chainID, addrBytes, mnemonic)) + return nil +} + +func (r *Relayer) FlushAcknowledgements(ctx context.Context, rep ibc.RelayerExecReporter, pathName, channelID string) error { + return r.FlushPackets(ctx, rep, pathName, channelID) +} + +func (r *Relayer) FlushPackets(ctx context.Context, rep ibc.RelayerExecReporter, pathName, channelID string) error { + path := r.paths[pathName] + cmd := []string{hermes, "clear", "packets", "--chain", path.chainA.chainID, "--channel", channelID, "--port", path.chainA.portID} + res := r.Exec(ctx, rep, cmd, nil) + return res.Err +} + +// GeneratePath establishes an in memory path representation. The concept does not exist in hermes, so it is handled +// at the interchain test level. +func (r *Relayer) GeneratePath(ctx context.Context, rep ibc.RelayerExecReporter, srcChainID, dstChainID, pathName string) error { + if r.paths == nil { + r.paths = map[string]*pathConfiguration{} + } + r.paths[pathName] = &pathConfiguration{ + chainA: pathChainConfig{ + chainID: srcChainID, + }, + chainB: pathChainConfig{ + chainID: dstChainID, + }, + } + return nil +} + +// configContent returns the contents of the hermes config file as a byte array. Note: as hermes expects a single file +// rather than multiple config files, we need to maintain a list of chain configs each time they are added to write the +// full correct file update calling Relayer.AddChainConfiguration. +func (r *Relayer) configContent(cfg ibc.ChainConfig, keyName, rpcAddr, grpcAddr string) ([]byte, error) { + r.chainConfigs = append(r.chainConfigs, ChainConfig{ + cfg: cfg, + keyName: keyName, + rpcAddr: rpcAddr, + grpcAddr: grpcAddr, + }) + hermesConfig := NewConfig(r.chainConfigs...) + bz, err := toml.Marshal(hermesConfig) + if err != nil { + return nil, err + } + return bz, nil +} + +// validateConfig validates the hermes config file. Any errors are propagated to the test. +func (r *Relayer) validateConfig(ctx context.Context, rep ibc.RelayerExecReporter) error { + cmd := []string{hermes, "--config", fmt.Sprintf("%s/%s", r.HomeDir(), hermesConfigPath), "config", "validate"} + res := r.Exec(ctx, rep, cmd, nil) + if res.Err != nil { + return res.Err + } + return nil +} + +// extractJsonResult extracts the json result for the hermes query. +func extractJsonResult(stdout []byte) []byte { + stdoutLines := strings.Split(string(stdout), "\n") + var jsonOutput string + for _, line := range stdoutLines { + if strings.Contains(line, "result") { + jsonOutput = line + break + } + } + return []byte(jsonOutput) +} + +// getClientIdFromStdout extracts the client ID from stdout. +func getClientIdFromStdout(stdout []byte) (string, error) { + var clientCreationResult ClientCreationResponse + if err := json.Unmarshal(extractJsonResult(stdout), &clientCreationResult); err != nil { + return "", err + } + return clientCreationResult.Result.CreateClient.ClientID, nil +} + +// getConnectionIDsFromStdout extracts the connectionIDs on both ends from the stdout. +func getConnectionIDsFromStdout(stdout []byte) (string, string, error) { + var connectionResponse ConnectionResponse + if err := json.Unmarshal(extractJsonResult(stdout), &connectionResponse); err != nil { + return "", "", err + } + return connectionResponse.Result.ASide.ConnectionID, connectionResponse.Result.BSide.ConnectionID, nil +} + +// parseRestoreKeyOutput extracts the address from the hermes output. +func parseRestoreKeyOutput(stdout string) string { + fullMatchIdx, addressGroupIdx := 0, 1 + return parseRestoreKeyOutputPattern.FindAllStringSubmatch(stdout, -1)[fullMatchIdx][addressGroupIdx] +} diff --git a/relayer/hermes/hermes_types.go b/relayer/hermes/hermes_types.go new file mode 100644 index 000000000..0a13c286c --- /dev/null +++ b/relayer/hermes/hermes_types.go @@ -0,0 +1,95 @@ +package hermes + +// ClientCreationResponse contains the minimum required values to extract the client id from the hermes response. +type ClientCreationResponse struct { + Result CreateClientResult `json:"result"` +} + +type CreateClient struct { + ClientID string `json:"client_id"` + ClientType string `json:"client_type"` +} + +type CreateClientResult struct { + CreateClient CreateClient `json:"CreateClient"` +} + +// ConnectionResponse contains the minimum required values to extract the connection id from both sides. +type ConnectionResponse struct { + Result ConnectionResult `json:"result"` +} + +type ConnectionResult struct { + ASide ConnectionSide `json:"a_side"` + BSide ConnectionSide `json:"b_side"` +} + +type ConnectionSide struct { + ConnectionID string `json:"connection_id"` +} + +// ChannelOutputResult contains the minimum required channel values. +type ChannelOutputResult struct { + Result []ChannelResult `json:"result"` +} + +type ChannelResult struct { + ChannelEnd ChannelEnd `json:"channel_end"` + CounterPartyChannelEnd ChannelEnd `json:"counterparty_channel_end"` +} + +type ChannelEnd struct { + ConnectionHops []string `json:"connection_hops"` + Ordering string `json:"ordering"` + State string `json:"state"` + Version string `json:"version"` + Remote ChannelAndPortId `json:"remote"` +} + +type ChannelAndPortId struct { + ChannelID string `json:"channel_id"` + PortID string `json:"port_id"` +} + +type ConnectionQueryResult struct { + Result []Result `json:"result"` + Status string `json:"status"` +} + +type Counterparty struct { + ClientID string `json:"client_id"` + ConnectionID string `json:"connection_id"` + Prefix string `json:"prefix"` +} + +type DelayPeriod struct { + Nanos int `json:"nanos"` + Secs int `json:"secs"` +} + +type Versions struct { + Features []string `json:"features"` + Identifier string `json:"identifier"` +} + +type ConnectionEnd struct { + ClientID string `json:"client_id"` + Counterparty Counterparty `json:"counterparty"` + DelayPeriod DelayPeriod `json:"delay_period"` + State string `json:"state"` + Versions []Versions `json:"versions"` +} + +type Result struct { + ConnectionEnd ConnectionEnd `json:"connection_end"` + ConnectionID string `json:"connection_id"` +} + +type ClientQueryResult struct { + ClientResult []ClientResult `json:"result"` +} + +type ClientResult struct { + ChainID string `json:"chain_id"` + ClientID string `json:"client_id"` +} diff --git a/relayer/hermes/hermes_wallet.go b/relayer/hermes/hermes_wallet.go new file mode 100644 index 000000000..064231054 --- /dev/null +++ b/relayer/hermes/hermes_wallet.go @@ -0,0 +1,42 @@ +package hermes + +import "github.com/strangelove-ventures/interchaintest/v6/ibc" + +var _ ibc.Wallet = &Wallet{} + +type WalletModel struct { + Mnemonic string `json:"mnemonic"` + Address string `json:"address"` +} + +type Wallet struct { + mnemonic string + address string + keyName string +} + +func NewWallet(keyname string, address string, mnemonic string) *Wallet { + return &Wallet{ + mnemonic: mnemonic, + address: address, + keyName: keyname, + } +} + +func (w *Wallet) KeyName() string { + return w.keyName +} + +func (w *Wallet) FormattedAddress() string { + return w.address +} + +// Get mnemonic, only used for relayer wallets +func (w *Wallet) Mnemonic() string { + return w.mnemonic +} + +// Get Address +func (w *Wallet) Address() []byte { + return []byte(w.address) +} diff --git a/relayer/options.go b/relayer/options.go index 7ae065f56..83ec1bace 100644 --- a/relayer/options.go +++ b/relayer/options.go @@ -16,6 +16,13 @@ type RelayerOptionDockerImage struct { DockerImage ibc.DockerImage } +// RelayerOptionHomeDir allows the configuration of the relayer home directory. +type RelayerOptionHomeDir struct { + HomeDir string +} + +func (r RelayerOptionHomeDir) relayerOption() {} + // CustomDockerImage overrides the default relayer docker image. // uidGid is the uid:gid format owner that should be used within the container. // If uidGid is empty, root user will be assumed. @@ -29,6 +36,10 @@ func CustomDockerImage(repository string, version string, uidGid string) Relayer } } +func HomeDir(homeDir string) RelayerOption { + return RelayerOptionHomeDir{HomeDir: homeDir} +} + func (opt RelayerOptionDockerImage) relayerOption() {} type RelayerOptionImagePull struct { diff --git a/relayer/rly/cosmos_relayer.go b/relayer/rly/cosmos_relayer.go index 643e60ac8..18d393d9e 100644 --- a/relayer/rly/cosmos_relayer.go +++ b/relayer/rly/cosmos_relayer.go @@ -14,7 +14,9 @@ import ( "go.uber.org/zap" ) -const RlyDefaultUidGid = "100:1000" +const ( + RlyDefaultUidGid = "100:1000" +) // CosmosRelayer is the ibc.Relayer implementation for github.com/cosmos/relayer. type CosmosRelayer struct { @@ -155,7 +157,7 @@ func (commander) CreateClient(pathName, homeDir, customeClientTrustingPeriod str } } -func (commander) CreateConnections(pathName, homeDir string) []string { +func (commander) CreateConnections(pathName string, homeDir string) []string { return []string{ "rly", "tx", "connection", pathName, "--home", homeDir, diff --git a/relayerfactory.go b/relayerfactory.go index 3a7a940e5..c2e5c4b57 100644 --- a/relayerfactory.go +++ b/relayerfactory.go @@ -8,6 +8,7 @@ import ( "github.com/strangelove-ventures/interchaintest/v6/ibc" "github.com/strangelove-ventures/interchaintest/v6/label" "github.com/strangelove-ventures/interchaintest/v6/relayer" + "github.com/strangelove-ventures/interchaintest/v6/relayer/hermes" "github.com/strangelove-ventures/interchaintest/v6/relayer/rly" "go.uber.org/zap" ) @@ -65,6 +66,8 @@ func (f builtinRelayerFactory) Build( networkID, f.options..., ) + case ibc.Hermes: + return hermes.NewHermesRelayer(f.log, t.Name(), cli, networkID, f.options...) default: panic(fmt.Errorf("RelayerImplementation %v unknown", f.impl)) } @@ -83,6 +86,14 @@ func (f builtinRelayerFactory) Name() string { } } return "rly@" + rly.DefaultContainerVersion + case ibc.Hermes: + for _, opt := range f.options { + switch o := opt.(type) { + case relayer.RelayerOptionDockerImage: + return "hermes@" + o.DockerImage.Version + } + } + return "hermes@" + hermes.DefaultContainerVersion default: panic(fmt.Errorf("RelayerImplementation %v unknown", f.impl)) } @@ -92,6 +103,8 @@ func (f builtinRelayerFactory) Labels() []label.Relayer { switch f.impl { case ibc.CosmosRly: return []label.Relayer{label.Rly} + case ibc.Hermes: + return []label.Relayer{label.Hermes} default: panic(fmt.Errorf("RelayerImplementation %v unknown", f.impl)) } @@ -103,6 +116,9 @@ func (f builtinRelayerFactory) Capabilities() map[relayer.Capability]bool { switch f.impl { case ibc.CosmosRly: return rly.Capabilities() + case ibc.Hermes: + // TODO: specify capability for hermes. + return rly.Capabilities() default: panic(fmt.Errorf("RelayerImplementation %v unknown", f.impl)) } diff --git a/test_setup.go b/test_setup.go index 00815b4e1..045bc9ed7 100644 --- a/test_setup.go +++ b/test_setup.go @@ -128,10 +128,8 @@ func StopStartRelayerWithPreStartFuncs( return nil, fmt.Errorf("failed to start relayer: %w", err) } } else { - for _, path := range pathNames { - if err := relayerImpl.StartRelayer(ctx, eRep, path); err != nil { - return nil, fmt.Errorf("failed to start relayer: %w", err) - } + if err := relayerImpl.StartRelayer(ctx, eRep, pathNames...); err != nil { + return nil, fmt.Errorf("failed to start relayer: %w", err) } }