From 0645be6bedf15708e8bdccaaa90e9f357d0e2adf Mon Sep 17 00:00:00 2001 From: Andrew Gouin Date: Fri, 15 Apr 2022 12:27:24 -0600 Subject: [PATCH] wasm tests (#6) * add contract Chain interface methods. implement for wasm. stub out wasm tests * Reproduce Juno Halt (#7) * WIP juno halt test * Should be dir not base * Fix contract deployment * use different version for groups of vals * Dump contract state * Use correct bad contract * Remove harcoded juno versions * Simplify test * add git ignore * Test post halt genesis (#8) * Testing post halt genesis * replace all instances * Also replace valcons addresses * Add juno post halt genesis test for IBC * Fix gas assertions. Move juno tests to trophies folder. Make relay test end to end (both directions IBC). cleanup (#10) * Fix gas in timeout tests * Add custom command to run tests for custom chain configurations (#11) Co-authored-by: Jack Zampolin Co-authored-by: Jack Zampolin Co-authored-by: Jack Zampolin --- .gitignore | 1 + assets/badcontract.wasm | Bin 0 -> 161679 bytes cmd/custom.go | 158 ++++++++++++++++ cmd/test.go | 9 +- go.mod | 13 +- go.sum | 21 +- ibc/Chain.go | 31 ++- ibc/Relayer.go | 3 + ibc/cosmos_chain.go | 262 +++++++++++++++++++++++-- ibc/cosmos_relayer.go | 22 ++- ibc/test_chains.go | 6 +- ibc/test_node.go | 300 ++++++++++++++++++++++++++++- ibc/test_relay.go | 196 ++++++++++++------- ibc/test_setup.go | 115 ++++++++--- trophies/test_juno.go | 410 ++++++++++++++++++++++++++++++++++++++++ 15 files changed, 1406 insertions(+), 141 deletions(-) create mode 100644 .gitignore create mode 100644 assets/badcontract.wasm create mode 100644 cmd/custom.go create mode 100644 trophies/test_juno.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..9d8716542 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +ibc-test-framework diff --git a/assets/badcontract.wasm b/assets/badcontract.wasm new file mode 100644 index 0000000000000000000000000000000000000000..6a6ac22b3ff1dc728c6f5a64cfe3a031669fab57 GIT binary patch literal 161679 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%0A7x4eE4#Zz(>)^B%OLHyxh)pu{T?$C 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 z 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 literal 0 HcmV?d00001 diff --git a/cmd/custom.go b/cmd/custom.go new file mode 100644 index 000000000..d0d6753ee --- /dev/null +++ b/cmd/custom.go @@ -0,0 +1,158 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/strangelove-ventures/ibc-test-framework/ibc" + "golang.org/x/sync/errgroup" +) + +// customCmd represents the custom command +var customCmd = &cobra.Command{ + Use: "custom", + Short: "Run with custom chain configurations", + Long: `This command allows you to provide all of the possible configuration parameters +for spinning up the source and destination chains +`, + Run: func(cmd *cobra.Command, args []string) { + flags := cmd.Flags() + relayerImplementationString, _ := flags.GetString("relayer") + testCasesString := args[0] + + relayerImplementation := parseRelayerImplementation(relayerImplementationString) + + srcType, _ := flags.GetString("src-type") + dstType, _ := flags.GetString("dst-type") + + // only cosmos chains supported for now + switch srcType { + case "cosmos": + break + default: + panic(fmt.Sprintf("chain type not supported: %s", srcType)) + } + + switch dstType { + case "cosmos": + break + default: + panic(fmt.Sprintf("chain type not supported: %s", dstType)) + } + + srcVals, _ := flags.GetInt("src-vals") + dstVals, _ := flags.GetInt("dst-vals") + + srcChainID, _ := flags.GetString("src-chain-id") + dstChainID, _ := flags.GetString("dst-chain-id") + + srcName, _ := flags.GetString("src-name") + dstName, _ := flags.GetString("dst-name") + + srcImage, _ := flags.GetString("src-image") + dstImage, _ := flags.GetString("dst-image") + + srcVersion, _ := flags.GetString("src-version") + dstVersion, _ := flags.GetString("dst-version") + + srcBinary, _ := flags.GetString("src-binary") + dstBinary, _ := flags.GetString("dst-binary") + + srcBech32Prefix, _ := flags.GetString("src-bech32") + dstBech32Prefix, _ := flags.GetString("dst-bech32") + + srcDenom, _ := flags.GetString("src-denom") + dstDenom, _ := flags.GetString("dst-denom") + + srcGasPrices, _ := flags.GetString("src-gas-prices") + dstGasPrices, _ := flags.GetString("dst-gas-prices") + + srcGasAdjustment, _ := flags.GetFloat64("src-gas-adjustment") + dstGasAdjustment, _ := flags.GetFloat64("dst-gas-adjustment") + + srcTrustingPeriod, _ := flags.GetString("src-trusting-period") + dstTrustingPeriod, _ := flags.GetString("dst-trusting-period") + + parallel, _ := flags.GetBool("parallel") + + srcChainCfg := ibc.NewCosmosChainConfig(srcName, srcImage, srcBinary, srcBech32Prefix, srcDenom, srcGasPrices, srcGasAdjustment, srcTrustingPeriod) + dstChainCfg := ibc.NewCosmosChainConfig(dstName, dstImage, dstBinary, dstBech32Prefix, dstDenom, dstGasPrices, dstGasAdjustment, dstTrustingPeriod) + + srcChainCfg.ChainID = srcChainID + dstChainCfg.ChainID = dstChainID + + srcChainCfg.Version = srcVersion + dstChainCfg.Version = dstVersion + + var testCases []func(testName string, srcChain ibc.Chain, dstChain ibc.Chain, relayerImplementation ibc.RelayerImplementation) error + + for _, testCaseString := range strings.Split(testCasesString, ",") { + testCase, err := ibc.GetTestCase(testCaseString) + if err != nil { + panic(err) + } + testCases = append(testCases, testCase) + } + + if parallel { + var eg errgroup.Group + for i, testCase := range testCases { + testCase := testCase + testName := fmt.Sprintf("Test%d", i) + srcChain := ibc.NewCosmosChain(testName, srcChainCfg, srcVals, 1) + dstChain := ibc.NewCosmosChain(testName, dstChainCfg, dstVals, 1) + eg.Go(func() error { + return testCase(testName, srcChain, dstChain, relayerImplementation) + }) + } + if err := eg.Wait(); err != nil { + panic(err) + } + } else { + for i, testCase := range testCases { + testName := fmt.Sprintf("Test%d", i) + srcChain := ibc.NewCosmosChain(testName, srcChainCfg, srcVals, 1) + dstChain := ibc.NewCosmosChain(testName, dstChainCfg, dstVals, 1) + if err := testCase(testName, srcChain, dstChain, relayerImplementation); err != nil { + panic(err) + } + } + } + fmt.Println("PASS") + }, +} + +func init() { + testCmd.AddCommand(customCmd) + + customCmd.Flags().StringP("src-name", "s", "gaia", "Source chain name") + customCmd.Flags().String("src-type", "cosmos", "Type of source chain") + customCmd.Flags().String("src-bech32", "cosmos", "Bech32 prefix for source chain") + customCmd.Flags().String("src-denom", "uatom", "Native denomination for source chain") + customCmd.Flags().String("src-gas-prices", "0.01uatom", "Gas prices for source chain") + customCmd.Flags().Float64("src-gas-adjustment", 1.3, "Gas adjustment for source chain") + customCmd.Flags().String("src-trust", "504h", "Trusting period for source chain ") + customCmd.Flags().String("src-image", "ghcr.io/strangelove-ventures/heighliner/gaia", "Docker image for source chain") + customCmd.Flags().String("src-version", "v7.0.1", "Docker image version for source chain") + customCmd.Flags().String("src-binary", "gaiad", "Binary for source chain") + customCmd.Flags().String("src-chain-id", "srcchain-1", "Chain ID to use for the source chain") + customCmd.Flags().Int("src-vals", 4, "Number of Validator nodes on source chain") + + customCmd.Flags().StringP("dst-name", "d", "gaia", "Destination chain name") + customCmd.Flags().String("dst-type", "cosmos", "Type of destination chain") + customCmd.Flags().String("dst-bech32", "cosmos", "Bech32 prefix for destination chain") + customCmd.Flags().String("dst-denom", "uatom", "Native denomination for destination chain") + customCmd.Flags().String("dst-gas-prices", "0.01uatom", "Gas prices for destination chain") + customCmd.Flags().Float64("dst-gas-adjustment", 1.3, "Gas adjustment for destination chain") + customCmd.Flags().String("dst-trust", "504h", "Trusting period for destination chain") + customCmd.Flags().String("dst-image", "ghcr.io/strangelove-ventures/heighliner/gaia", "Docker image for destination chain") + customCmd.Flags().String("dst-version", "v7.0.1", "Docker image version for destination chain") + customCmd.Flags().String("dst-binary", "gaiad", "Binary for destination chain") + customCmd.Flags().String("dst-chain-id", "dstchain-1", "Chain ID to use for the source chain") + customCmd.Flags().Int("dst-vals", 4, "Number of Validator nodes on destination chain") + + customCmd.Flags().StringP("relayer", "r", "rly", "Relayer implementation to use (rly or hermes)") + customCmd.Flags().BoolP("parallel", "p", false, "Run tests in parallel") + +} diff --git a/cmd/test.go b/cmd/test.go index a716d16ca..f2988844b 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -61,13 +61,12 @@ e.g. ibc-test-framework test # Specify specific chains/versions, relayer implementation, and test cases -ibc-test-framework test --source osmosis:v7.0.4 --destination juno:v2.3.0 --relayer rly RelayPacketTest,RelayPacketTestHeightTimeout +ibc-test-framework test --src osmosis:v7.0.4 --dst juno:v2.3.0 --relayer rly RelayPacketTest,RelayPacketTestHeightTimeout # Shorthand flags -ibc-test-framework test -src osmosis:v7.0.4 -dst juno:v2.3.0 -r rly RelayPacketTest +ibc-test-framework test -s osmosis:v7.0.4 -d juno:v2.3.0 -r rly RelayPacketTest `, Run: func(cmd *cobra.Command, args []string) { - fmt.Println("IBC Test Framework") flags := cmd.Flags() srcChainNameVersion, _ := flags.GetString("src") dstChainNameVersion, _ := flags.GetString("dst") @@ -101,7 +100,7 @@ ibc-test-framework test -src osmosis:v7.0.4 -dst juno:v2.3.0 -r rly RelayPacketT var eg errgroup.Group for i, testCase := range testCases { testCase := testCase - testName := fmt.Sprintf("RelayTest%d", i) + testName := fmt.Sprintf("Test%d", i) eg.Go(func() error { return runTestCase(testName, testCase, relayerImplementation, srcChainName, srcChainVersion, srcChainID, srcVals, dstChainName, dstChainVersion, dstChainID, dstVals) }) @@ -111,7 +110,7 @@ ibc-test-framework test -src osmosis:v7.0.4 -dst juno:v2.3.0 -r rly RelayPacketT } } else { for i, testCase := range testCases { - testName := fmt.Sprintf("RelayTest%d", i) + testName := fmt.Sprintf("Test%d", i) if err := runTestCase(testName, testCase, relayerImplementation, srcChainName, srcChainVersion, srcChainID, srcVals, dstChainName, dstChainVersion, dstChainID, dstVals); err != nil { panic(err) } diff --git a/go.mod b/go.mod index cc24fdf8f..96826db0e 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/cosmos/cosmos-sdk v0.45.1 github.com/cosmos/ibc-go/v3 v3.0.0 github.com/ory/dockertest v3.3.5+incompatible + github.com/spf13/cobra v1.4.0 github.com/stretchr/testify v1.7.0 github.com/tendermint/tendermint v0.34.14 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c @@ -54,6 +55,7 @@ require ( github.com/golang/protobuf v1.5.2 // indirect github.com/golang/snappy v0.0.3 // indirect github.com/google/btree v1.0.0 // indirect + github.com/google/go-cmp v0.5.7 // indirect github.com/gorilla/handlers v1.5.1 // indirect github.com/gorilla/mux v1.8.0 // indirect github.com/gorilla/websocket v1.4.2 // indirect @@ -95,7 +97,6 @@ require ( github.com/sirupsen/logrus v1.8.1 // indirect github.com/spf13/afero v1.6.0 // indirect github.com/spf13/cast v1.4.1 // indirect - github.com/spf13/cobra v1.4.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/viper v1.10.1 // indirect @@ -109,12 +110,12 @@ require ( github.com/tendermint/tm-db v0.6.4 // indirect github.com/zondax/hid v0.9.0 // indirect go.etcd.io/bbolt v1.3.5 // indirect - golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect - golang.org/x/net v0.0.0-20210903162142-ad29c8ab022f // indirect - golang.org/x/sys v0.0.0-20211210111614-af8b64212486 // indirect - golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect + golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70 // indirect + golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect + golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect golang.org/x/text v0.3.7 // indirect - google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa // indirect + google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf // indirect google.golang.org/protobuf v1.27.1 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/ini.v1 v1.66.2 // indirect diff --git a/go.sum b/go.sum index f647ea3a7..cd33717a2 100644 --- a/go.sum +++ b/go.sum @@ -394,8 +394,9 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.1-0.20200604201612-c04b05f3adfa h1:Q75Upo5UN4JbPFURXZ8nLKYUvF85dyFRop/vQ0Rv+64= @@ -833,7 +834,6 @@ github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tL github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI= github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk= -github.com/spf13/cobra v1.3.0 h1:R7cSvGu+Vv+qX0gW5R/85dx2kmmJT5z5NM8ifdYjdn0= github.com/spf13/cobra v1.3.0/go.mod h1:BrRVncBjOJa/eUcVVm9CE+oC6as8k+VYr4NY7WCi9V4= github.com/spf13/cobra v1.4.0 h1:y+wJpx64xcgO1V+RcnwW0LEHxTKRi2ZDPSBjWnrg88Q= github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g= @@ -969,8 +969,9 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70 h1:syTAU9FwmvzEoIYMqcPHOcVm4H3U5u90WsvuYgwpETU= +golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -1067,8 +1068,10 @@ golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210903162142-ad29c8ab022f h1:w6wWR0H+nyVpbSAQbzVEIACVyr/h8l/BEkY6Sokc7Eg= golang.org/x/net v0.0.0-20210903162142-ad29c8ab022f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd h1:O7DYs+zxREGLKzKoMQrtrEacpb0ZVXA5rIwylE2Xchk= +golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1192,11 +1195,14 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211210111614-af8b64212486 h1:5hpz5aRr+W1erYCL5JRhSUBJRph7l9XkNveoExlrKYk= golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1391,8 +1397,9 @@ google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ6 google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= -google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa h1:I0YcKz0I7OAhddo7ya8kMnvprhcWM045PmkBdMO9zN0= google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf h1:SVYXkUz2yZS9FWb2Gm8ivSlbNQzL2Z/NpPKE3RG2jWk= +google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.19.1/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= diff --git a/ibc/Chain.go b/ibc/Chain.go index 09329e830..ff5d5a03d 100644 --- a/ibc/Chain.go +++ b/ibc/Chain.go @@ -42,28 +42,55 @@ type Chain interface { // sets up everything needed (validators, gentx, fullnodes, peering, additional accounts) for chain to start from genesis Start(testName string, ctx context.Context, additionalGenesisWallets []WalletAmount) error + // start a chain with a provided genesis file. Will override validators for first 2/3 of voting power + StartWithGenesisFile(testName string, ctx context.Context, home string, pool *dockertest.Pool, networkID string, genesisFilePath string) error + + // export state at specific height + ExportState(ctx context.Context, height int64) (string, error) + // retrieves rpc address that can be reached by other containers in the docker network GetRPCAddress() string // retrieves grpc address that can be reached by other containers in the docker network GetGRPCAddress() string + // get current height + Height() (int64, error) + // creates a test key in the "user" node, (either the first fullnode or the first validator if no fullnodes) CreateKey(ctx context.Context, keyName string) error // fetches the bech32 address for a test key on the "user" node (either the first fullnode or the first validator if no fullnodes) GetAddress(keyName string) ([]byte, error) + // send funds to wallet from user account + SendFunds(ctx context.Context, keyName string, amount WalletAmount) error + // sends an IBC transfer from a test key on the "user" node (either the first fullnode or the first validator if no fullnodes) // returns tx hash SendIBCTransfer(ctx context.Context, channelID, keyName string, amount WalletAmount, timeout *IBCTimeout) (string, error) - // waits for # of blocks to be produced - WaitForBlocks(number int64) error + // takes file path to smart contract and initialization message. returns contract address + InstantiateContract(ctx context.Context, keyName string, amount WalletAmount, fileName, initMessage string, needsNoAdminFlag bool) (string, error) + + // executes a contract transaction with a message using it's address + ExecuteContract(ctx context.Context, keyName string, contractAddress string, message string) error + + // dump state of contract at block height + DumpContractState(ctx context.Context, contractAddress string, height int64) (*DumpContractStateResponse, error) + + // create balancer pool + CreatePool(ctx context.Context, keyName string, contractAddress string, swapFee float64, exitFee float64, assets []WalletAmount) error + + // waits for # of blocks to be produced. Returns latest height + WaitForBlocks(number int64) (int64, error) // fetch balance for a specific account address and denom GetBalance(ctx context.Context, address string, denom string) (int64, error) + // get the fees in native denom for an amount of spent gas + GetGasFeesInNativeDenom(gasPaid int64) int64 + // fetch transaction GetTransaction(ctx context.Context, txHash string) (*types.TxResponse, error) } diff --git a/ibc/Relayer.go b/ibc/Relayer.go index 99453ff95..3d1683d63 100644 --- a/ibc/Relayer.go +++ b/ibc/Relayer.go @@ -40,6 +40,9 @@ type Relayer interface { // setup channels, connections, and clients LinkPath(ctx context.Context, pathName string) error + // update clients, such as after new genesis + UpdateClients(ctx context.Context, pathName string) error + // get channel IDs for chain GetChannels(ctx context.Context, chainID string) ([]ChannelOutput, error) diff --git a/ibc/cosmos_chain.go b/ibc/cosmos_chain.go index 2a6122c16..185d1006a 100644 --- a/ibc/cosmos_chain.go +++ b/ibc/cosmos_chain.go @@ -1,13 +1,22 @@ package ibc import ( + "bytes" "context" + "encoding/hex" + "encoding/json" "fmt" "io/ioutil" + "math" "os" "path" + "sort" + "strconv" + "strings" + "time" "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/bech32" authTx "github.com/cosmos/cosmos-sdk/x/auth/tx" bankTypes "github.com/cosmos/cosmos-sdk/x/bank/types" @@ -23,10 +32,31 @@ type CosmosChain struct { cfg ChainConfig numValidators int numFullNodes int - chainNodes ChainNodes + ChainNodes ChainNodes } func NewCosmosChainConfig(name string, + dockerImage string, + binary string, + bech32Prefix string, + denom string, + gasPrices string, + gasAdjustment float64, + trustingPeriod string) ChainConfig { + return ChainConfig{ + Type: "cosmos", + Name: name, + Bech32Prefix: bech32Prefix, + Denom: denom, + GasPrices: gasPrices, + GasAdjustment: gasAdjustment, + TrustingPeriod: trustingPeriod, + Repository: dockerImage, + Bin: binary, + } +} + +func NewCosmosHeighlinerChainConfig(name string, binary string, bech32Prefix string, denom string, @@ -67,12 +97,12 @@ func (c *CosmosChain) Initialize(testName string, homeDirectory string, dockerPo } func (c *CosmosChain) getRelayerNode() *ChainNode { - if len(c.chainNodes) > c.numValidators { + if len(c.ChainNodes) > c.numValidators { // use first full node - return c.chainNodes[c.numValidators] + return c.ChainNodes[c.numValidators] } // use first validator - return c.chainNodes[0] + return c.ChainNodes[0] } // Implements Chain interface @@ -100,16 +130,50 @@ func (c *CosmosChain) GetAddress(keyName string) ([]byte, error) { return keyInfo.GetAddress().Bytes(), nil } +// Implements Chain interface +func (c *CosmosChain) SendFunds(ctx context.Context, keyName string, amount WalletAmount) error { + return c.getRelayerNode().SendFunds(ctx, keyName, amount) +} + // Implements Chain interface func (c *CosmosChain) SendIBCTransfer(ctx context.Context, channelID, keyName string, amount WalletAmount, timeout *IBCTimeout) (string, error) { return c.getRelayerNode().SendIBCTransfer(ctx, channelID, keyName, amount, timeout) } // Implements Chain interface -func (c *CosmosChain) WaitForBlocks(number int64) error { +func (c *CosmosChain) InstantiateContract(ctx context.Context, keyName string, amount WalletAmount, fileName, initMessage string, needsNoAdminFlag bool) (string, error) { + return c.getRelayerNode().InstantiateContract(ctx, keyName, amount, fileName, initMessage, needsNoAdminFlag) +} + +// Implements Chain interface +func (c *CosmosChain) ExecuteContract(ctx context.Context, keyName string, contractAddress string, message string) error { + return c.getRelayerNode().ExecuteContract(ctx, keyName, contractAddress, message) +} + +// Implements Chain interface +func (c *CosmosChain) DumpContractState(ctx context.Context, contractAddress string, height int64) (*DumpContractStateResponse, error) { + return c.getRelayerNode().DumpContractState(ctx, contractAddress, height) +} + +// Implements Chain interface +func (c *CosmosChain) ExportState(ctx context.Context, height int64) (string, error) { + return c.getRelayerNode().ExportState(ctx, height) +} + +// Implements Chain interface +func (c *CosmosChain) CreatePool(ctx context.Context, keyName string, contractAddress string, swapFee float64, exitFee float64, assets []WalletAmount) error { + return c.getRelayerNode().CreatePool(ctx, keyName, contractAddress, swapFee, exitFee, assets) +} + +// Implements Chain interface +func (c *CosmosChain) WaitForBlocks(number int64) (int64, error) { return c.getRelayerNode().WaitForBlocks(number) } +func (c *CosmosChain) Height() (int64, error) { + return c.getRelayerNode().Height() +} + // Implements Chain interface func (c *CosmosChain) GetBalance(ctx context.Context, address string, denom string) (int64, error) { params := &bankTypes.QueryBalanceRequest{Address: address, Denom: denom} @@ -134,10 +198,16 @@ func (c *CosmosChain) GetTransaction(ctx context.Context, txHash string) (*types return authTx.QueryTx(c.getRelayerNode().CliContext(), txHash) } +func (c *CosmosChain) GetGasFeesInNativeDenom(gasPaid int64) int64 { + gasPrice, _ := strconv.ParseFloat(strings.Replace(c.cfg.GasPrices, c.cfg.Denom, "", 1), 64) + fees := float64(gasPaid) * gasPrice + return int64(fees) +} + // creates the test node objects required for bootstrapping tests func (c *CosmosChain) initializeChainNodes(testName, home string, pool *dockertest.Pool, networkID string) { - chainNodes := []*ChainNode{} + ChainNodes := []*ChainNode{} count := c.numValidators + c.numFullNodes chainCfg := c.Config() err := pool.Client.PullImage(docker.PullImageOptions{ @@ -151,9 +221,178 @@ func (c *CosmosChain) initializeChainNodes(testName, home string, tn := &ChainNode{Home: home, Index: i, Chain: c, Pool: pool, NetworkID: networkID, testName: testName} tn.MkDir() - chainNodes = append(chainNodes, tn) + ChainNodes = append(ChainNodes, tn) + } + c.ChainNodes = ChainNodes +} + +type GenesisValidatorPubKey struct { + Type string `json:"type"` + Value string `json:"value"` +} +type GenesisValidators struct { + Address string `json:"address"` + Name string `json:"name"` + Power string `json:"power"` + PubKey GenesisValidatorPubKey `json:"pub_key"` +} +type GenesisFile struct { + Validators []GenesisValidators `json:"validators"` +} + +type ValidatorWithIntPower struct { + Address string + Power int64 + PubKeyBase64 string +} + +// Bootstraps the chain and starts it from genesis +func (c *CosmosChain) StartWithGenesisFile(testName string, ctx context.Context, home string, pool *dockertest.Pool, networkID string, genesisFilePath string) error { + // copy genesis file to tmp path for modification + genesisTmpFilePath := path.Join(c.getRelayerNode().Dir(), "genesis_tmp.json") + if _, err := copy(genesisFilePath, genesisTmpFilePath); err != nil { + return err } - c.chainNodes = chainNodes + + chainCfg := c.Config() + + genesisJsonBytes, err := ioutil.ReadFile(genesisTmpFilePath) + if err != nil { + return err + } + + genesisFile := GenesisFile{} + if err := json.Unmarshal(genesisJsonBytes, &genesisFile); err != nil { + return err + } + + genesisValidators := genesisFile.Validators + totalPower := int64(0) + + validatorsWithPower := make([]ValidatorWithIntPower, 0) + + for _, genesisValidator := range genesisValidators { + power, err := strconv.ParseInt(genesisValidator.Power, 10, 64) + if err != nil { + return err + } + totalPower += power + validatorsWithPower = append(validatorsWithPower, ValidatorWithIntPower{ + Address: genesisValidator.Address, + Power: power, + PubKeyBase64: genesisValidator.PubKey.Value, + }) + } + + sort.Slice(validatorsWithPower, func(i, j int) bool { + return validatorsWithPower[i].Power > validatorsWithPower[j].Power + }) + + twoThirdsConsensus := int64(math.Ceil(float64(totalPower) * 2 / 3)) + totalConsensus := int64(0) + + c.ChainNodes = []*ChainNode{} + + for i, validator := range validatorsWithPower { + tn := &ChainNode{Home: home, Index: i, Chain: c, + Pool: pool, NetworkID: networkID, testName: testName} + tn.MkDir() + c.ChainNodes = append(c.ChainNodes, tn) + + // just need to get pubkey here + // don't care about what goes into this node's genesis file since it will be overwritten with the modified one + if err := tn.InitHomeFolder(ctx); err != nil { + return err + } + + testNodePubKeyJsonBytes, err := ioutil.ReadFile(tn.PrivValKeyFilePath()) + if err != nil { + return err + } + + testNodePrivValFile := PrivValidatorKeyFile{} + if err := json.Unmarshal(testNodePubKeyJsonBytes, &testNodePrivValFile); err != nil { + return err + } + + // modify genesis file overwriting validators address with the one generated for this test node + genesisJsonBytes = bytes.ReplaceAll(genesisJsonBytes, []byte(validator.Address), []byte(testNodePrivValFile.Address)) + + // modify genesis file overwriting validators base64 pub_key.value with the one generated for this test node + genesisJsonBytes = bytes.ReplaceAll(genesisJsonBytes, []byte(validator.PubKeyBase64), []byte(testNodePrivValFile.PubKey.Value)) + + existingValAddressBytes, err := hex.DecodeString(validator.Address) + if err != nil { + return err + } + + testNodeAddressBytes, err := hex.DecodeString(testNodePrivValFile.Address) + if err != nil { + return err + } + + valConsPrefix := fmt.Sprintf("%svalcons", chainCfg.Bech32Prefix) + + existingValBech32ValConsAddress, err := bech32.ConvertAndEncode(valConsPrefix, existingValAddressBytes) + if err != nil { + return err + } + + testNodeBech32ValConsAddress, err := bech32.ConvertAndEncode(valConsPrefix, testNodeAddressBytes) + if err != nil { + return err + } + + genesisJsonBytes = bytes.ReplaceAll(genesisJsonBytes, []byte(existingValBech32ValConsAddress), []byte(testNodeBech32ValConsAddress)) + + totalConsensus += validator.Power + + if totalConsensus > twoThirdsConsensus { + break + } + } + + for i := 0; i < len(c.ChainNodes); i++ { + if err := ioutil.WriteFile(c.ChainNodes[i].GenesisFilePath(), genesisJsonBytes, 0644); err != nil { //nolint + return err + } + } + + if err := ChainNodes(c.ChainNodes).LogGenesisHashes(); err != nil { + return err + } + + var eg errgroup.Group + + for _, n := range c.ChainNodes { + n := n + eg.Go(func() error { + return n.CreateNodeContainer() + }) + } + if err := eg.Wait(); err != nil { + return err + } + + peers := ChainNodes(c.ChainNodes).PeerString() + + for _, n := range c.ChainNodes { + n.SetValidatorConfigAndPeers(peers) + } + + for _, n := range c.ChainNodes { + n := n + fmt.Printf("{%s} => starting container...\n", n.Name()) + if err := n.StartContainer(ctx); err != nil { + return err + } + } + + time.Sleep(2 * time.Hour) + + // Wait for 5 blocks before considering the chains "started" + _, err = c.getRelayerNode().WaitForBlocks(5) + return err } // Bootstraps the chain and starts it from genesis @@ -179,8 +418,8 @@ func (c *CosmosChain) Start(testName string, ctx context.Context, additionalGene genesisAmounts := []types.Coin{genesisAmount, genesisStakeAmount} - validators := c.chainNodes[:c.numValidators] - fullnodes := c.chainNodes[c.numValidators:] + validators := c.ChainNodes[:c.numValidators] + fullnodes := c.ChainNodes[c.numValidators:] // sign gentx for each validator for _, v := range validators { @@ -281,5 +520,6 @@ func (c *CosmosChain) Start(testName string, ctx context.Context, additionalGene } // Wait for 5 blocks before considering the chains "started" - return c.getRelayerNode().WaitForBlocks(5) + _, err = c.getRelayerNode().WaitForBlocks(5) + return err } diff --git a/ibc/cosmos_relayer.go b/ibc/cosmos_relayer.go index aee577fd6..88e0d9808 100644 --- a/ibc/cosmos_relayer.go +++ b/ibc/cosmos_relayer.go @@ -48,7 +48,7 @@ type CosmosRelayerChainConfig struct { var ( containerImage = "ghcr.io/cosmos/relayer" - containerVersion = "latest" + containerVersion = "v2.0.0-beta4" ) func ChainConfigToCosmosRelayerChainConfig(chainConfig ChainConfig, keyName, rpcAddr, gprcAddr string) CosmosRelayerChainConfig { @@ -129,7 +129,14 @@ func (relayer *CosmosRelayer) StartRelayer(ctx context.Context, pathName string) // Implements Relayer interface func (relayer *CosmosRelayer) StopRelayer(ctx context.Context) error { - return relayer.StopContainer() + if err := relayer.StopContainer(); err != nil { + return err + } + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + _ = relayer.pool.Client.Logs(docker.LogsOptions{Context: ctx, Container: relayer.container.ID, OutputStream: stdout, ErrorStream: stderr, Stdout: true, Stderr: true, Tail: "50", Follow: false, Timestamps: false}) + fmt.Printf("{%s} - stdout:\n%s\n{%s} - stderr:\n%s\n", relayer.Name(), stdout.String(), relayer.Name(), stderr.String()) + return relayer.pool.Client.RemoveContainer(docker.RemoveContainerOptions{ID: relayer.container.ID}) } // Implements Relayer interface @@ -181,6 +188,13 @@ func (relayer *CosmosRelayer) GeneratePath(ctx context.Context, srcChainID, dstC return handleNodeJobError(relayer.NodeJob(ctx, command)) } +func (relayer *CosmosRelayer) UpdateClients(ctx context.Context, pathName string) error { + command := []string{"rly", "tx", "update-clients", pathName, + "--home", relayer.NodeHome(), + } + return handleNodeJobError(relayer.NodeJob(ctx, command)) +} + func (relayer *CosmosRelayer) CreateNodeContainer(pathName string) error { err := relayer.pool.Client.PullImage(docker.PullImageOptions{ Repository: containerImage, @@ -190,7 +204,7 @@ func (relayer *CosmosRelayer) CreateNodeContainer(pathName string) error { return err } containerName := fmt.Sprintf("%s-%s", relayer.Name(), pathName) - cmd := []string{"rly", "start", pathName, "--home", relayer.NodeHome()} + cmd := []string{"rly", "start", pathName, "--home", relayer.NodeHome(), "--debug"} fmt.Printf("{%s} -> '%s'\n", containerName, strings.Join(cmd, " ")) cont, err := relayer.pool.Client.CreateContainer(docker.CreateContainerOptions{ Name: containerName, @@ -266,7 +280,7 @@ func (relayer *CosmosRelayer) NodeJob(ctx context.Context, cmd []string) (int, s exitCode, err := relayer.pool.Client.WaitContainerWithContext(cont.ID, ctx) stdout := new(bytes.Buffer) stderr := new(bytes.Buffer) - _ = relayer.pool.Client.Logs(docker.LogsOptions{Context: ctx, Container: cont.ID, OutputStream: stdout, ErrorStream: stderr, Stdout: true, Stderr: true, Tail: "100", Follow: false, Timestamps: false}) + _ = relayer.pool.Client.Logs(docker.LogsOptions{Context: ctx, Container: cont.ID, OutputStream: stdout, ErrorStream: stderr, Stdout: true, Stderr: true, Tail: "50", Follow: false, Timestamps: false}) _ = relayer.pool.Client.RemoveContainer(docker.RemoveContainerOptions{ID: cont.ID}) fmt.Printf("{%s} - stdout:\n%s\n{%s} - stderr:\n%s\n", container, stdout.String(), container, stderr.String()) return exitCode, stdout.String(), stderr.String(), err diff --git a/ibc/test_chains.go b/ibc/test_chains.go index 51f5b49d2..af9187982 100644 --- a/ibc/test_chains.go +++ b/ibc/test_chains.go @@ -3,9 +3,9 @@ package ibc import "fmt" var chainConfigs = []ChainConfig{ - NewCosmosChainConfig("gaia", "gaiad", "cosmos", "uatom", "0.01uatom", 1.3, "504h"), - NewCosmosChainConfig("osmosis", "osmosisd", "osmo", "uosmo", "0.0uosmo", 1.3, "336h"), - NewCosmosChainConfig("juno", "junod", "juno", "ujuno", "0.0ujuno", 1.3, "672h"), + NewCosmosHeighlinerChainConfig("gaia", "gaiad", "cosmos", "uatom", "0.01uatom", 1.3, "504h"), + NewCosmosHeighlinerChainConfig("osmosis", "osmosisd", "osmo", "uosmo", "0.0uosmo", 1.3, "336h"), + NewCosmosHeighlinerChainConfig("juno", "junod", "juno", "ujuno", "0.0025ujuno", 1.3, "672h"), } var chainConfigMap map[string]ChainConfig diff --git a/ibc/test_node.go b/ibc/test_node.go index 3a6e62ce2..79b20af93 100644 --- a/ibc/test_node.go +++ b/ibc/test_node.go @@ -7,9 +7,11 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/ioutil" "os" "path" + "path/filepath" "runtime" "strings" "time" @@ -57,7 +59,7 @@ type Hosts []ContainerPort var ( valKey = "validator" - blockTime = 3 // seconds + blockTime = 2 // seconds p2pPort = "26656/tcp" rpcPort = "26657/tcp" grpcPort = "9090/tcp" @@ -134,6 +136,21 @@ func (tn *ChainNode) GenesisFilePath() string { return path.Join(tn.Dir(), "config", "genesis.json") } +type PrivValidatorKey struct { + Type string `json:"type"` + Value string `json:"value"` +} + +type PrivValidatorKeyFile struct { + Address string `json:"address"` + PubKey PrivValidatorKey `json:"pub_key"` + PrivKey PrivValidatorKey `json:"priv_key"` +} + +func (tn *ChainNode) PrivValKeyFilePath() string { + return path.Join(tn.Dir(), "config", "priv_validator_key.json") +} + func (tn *ChainNode) TMConfigPath() string { return path.Join(tn.Dir(), "config", "config.toml") } @@ -176,13 +193,14 @@ func (tn *ChainNode) SetPrivValdidatorListen(peers string) { } // Wait until we have signed n blocks in a row -func (tn *ChainNode) WaitForBlocks(blocks int64) error { +func (tn *ChainNode) WaitForBlocks(blocks int64) (int64, error) { stat, err := tn.Client.Status(context.Background()) if err != nil { - return err + return -1, err } startingBlock := stat.SyncInfo.LatestBlockHeight + mostRecentBlock := startingBlock fmt.Printf("{WaitForBlocks-%s} Initial Height: %d\n", tn.Chain.Config().ChainID, startingBlock) // timeout after ~1 minute plus block time timeoutSeconds := blocks*int64(blockTime) + int64(60) @@ -191,19 +209,27 @@ func (tn *ChainNode) WaitForBlocks(blocks int64) error { stat, err := tn.Client.Status(context.Background()) if err != nil { - return err + return mostRecentBlock, err } - mostRecentBlock := stat.SyncInfo.LatestBlockHeight + mostRecentBlock = stat.SyncInfo.LatestBlockHeight deltaBlocks := mostRecentBlock - startingBlock if deltaBlocks >= blocks { fmt.Printf("{WaitForBlocks-%s} Time (sec) waiting for %d blocks: %d\n", tn.Chain.Config().ChainID, blocks, i+1) - return nil // done waiting for consecutive signed blocks + return mostRecentBlock, nil // done waiting for consecutive signed blocks } } - return errors.New("timed out waiting for blocks") + return mostRecentBlock, errors.New("timed out waiting for blocks") +} + +func (tn *ChainNode) Height() (int64, error) { + stat, err := tn.Client.Status(context.Background()) + if err != nil { + return -1, err + } + return stat.SyncInfo.LatestBlockHeight, nil } func applyConfigChanges(cfg *tmconfig.Config, peers string) { @@ -286,6 +312,8 @@ func (tn *ChainNode) SendIBCTransfer(ctx context.Context, channelID string, keyN command := []string{tn.Chain.Config().Bin, "tx", "ibc-transfer", "transfer", "transfer", channelID, amount.Address, fmt.Sprintf("%d%s", amount.Amount, amount.Denom), "--keyring-backend", keyring.BackendTest, + "--gas-prices", tn.Chain.Config().GasPrices, + "--gas-adjustment", fmt.Sprint(tn.Chain.Config().GasAdjustment), "--node", fmt.Sprintf("tcp://%s:26657", tn.Name()), "--from", keyName, "--output", "json", @@ -312,10 +340,264 @@ func (tn *ChainNode) SendIBCTransfer(ctx context.Context, channelID string, keyN return output.TxHash, nil } +func (tn *ChainNode) SendFunds(ctx context.Context, keyName string, amount WalletAmount) error { + command := []string{tn.Chain.Config().Bin, "tx", "bank", "send", keyName, + amount.Address, fmt.Sprintf("%d%s", amount.Amount, amount.Denom), + "--keyring-backend", keyring.BackendTest, + "--node", fmt.Sprintf("tcp://%s:26657", tn.Name()), + "--output", "json", + "-y", + "--home", tn.NodeHome(), + "--chain-id", tn.Chain.Config().ChainID, + } + + return handleNodeJobError(tn.NodeJob(ctx, command)) +} + +func copy(src, dst string) (int64, error) { + sourceFileStat, err := os.Stat(src) + if err != nil { + return 0, err + } + + if !sourceFileStat.Mode().IsRegular() { + return 0, fmt.Errorf("%s is not a regular file", src) + } + + source, err := os.Open(src) + if err != nil { + return 0, err + } + defer source.Close() + + destination, err := os.Create(dst) + if err != nil { + return 0, err + } + defer destination.Close() + nBytes, err := io.Copy(destination, source) + return nBytes, err +} + +type InstantiateContractAttribute struct { + Value string `json:"value"` +} + +type InstantiateContractEvent struct { + Attributes []InstantiateContractAttribute `json:"attributes"` +} + +type InstantiateContractLog struct { + Events []InstantiateContractEvent `json:"event"` +} + +type InstantiateContractResponse struct { + Logs []InstantiateContractLog `json:"log"` +} + +type QueryContractResponse struct { + Contracts []string `json:"contracts"` +} + +type CodeInfo struct { + CodeID string `json:"code_id"` +} +type CodeInfosResponse struct { + CodeInfos []CodeInfo `json:"code_infos"` +} + +func (tn *ChainNode) InstantiateContract(ctx context.Context, keyName string, amount WalletAmount, fileName, initMessage string, needsNoAdminFlag bool) (string, error) { + _, file := filepath.Split(fileName) + newFilePath := path.Join(tn.Dir(), file) + newFilePathContainer := path.Join(tn.NodeHome(), file) + if _, err := copy(fileName, newFilePath); err != nil { + return "", err + } + + command := []string{tn.Chain.Config().Bin, "tx", "wasm", "store", newFilePathContainer, + "--from", keyName, + "--gas-prices", tn.Chain.Config().GasPrices, + "--gas-adjustment", fmt.Sprint(tn.Chain.Config().GasAdjustment), + "--keyring-backend", keyring.BackendTest, + "--node", fmt.Sprintf("tcp://%s:26657", tn.Name()), + "--output", "json", + "-y", + "--home", tn.NodeHome(), + "--chain-id", tn.Chain.Config().ChainID, + } + + exitCode, stdout, stderr, err := tn.NodeJob(ctx, command) + if err != nil { + return "", handleNodeJobError(exitCode, stdout, stderr, err) + } + + if _, err := tn.Chain.WaitForBlocks(5); err != nil { + return "", err + } + + command = []string{tn.Chain.Config().Bin, + "query", "wasm", "list-code", "--reverse", + "--node", fmt.Sprintf("tcp://%s:26657", tn.Name()), + "--output", "json", + "--home", tn.NodeHome(), + "--chain-id", tn.Chain.Config().ChainID, + } + + exitCode, stdout, stderr, err = tn.NodeJob(ctx, command) + if err != nil { + return "", handleNodeJobError(exitCode, stdout, stderr, err) + } + + res := CodeInfosResponse{} + if err := json.Unmarshal([]byte(stdout), &res); err != nil { + return "", err + } + + codeID := res.CodeInfos[0].CodeID + + command = []string{tn.Chain.Config().Bin, + "tx", "wasm", "instantiate", codeID, initMessage, + "--gas-prices", tn.Chain.Config().GasPrices, + "--gas-adjustment", fmt.Sprint(tn.Chain.Config().GasAdjustment), + "--label", "satoshi-test", + "--from", keyName, + "--keyring-backend", keyring.BackendTest, + "--node", fmt.Sprintf("tcp://%s:26657", tn.Name()), + "--output", "json", + "-y", + "--home", tn.NodeHome(), + "--chain-id", tn.Chain.Config().ChainID, + } + + if needsNoAdminFlag { + command = append(command, "--no-admin") + } + + exitCode, stdout, stderr, err = tn.NodeJob(ctx, command) + if err != nil { + return "", handleNodeJobError(exitCode, stdout, stderr, err) + } + + if _, err := tn.Chain.WaitForBlocks(5); err != nil { + return "", err + } + + command = []string{tn.Chain.Config().Bin, + "query", "wasm", "list-contract-by-code", codeID, + "--node", fmt.Sprintf("tcp://%s:26657", tn.Name()), + "--output", "json", + "--home", tn.NodeHome(), + "--chain-id", tn.Chain.Config().ChainID, + } + + exitCode, stdout, stderr, err = tn.NodeJob(ctx, command) + if err != nil { + return "", handleNodeJobError(exitCode, stdout, stderr, err) + } + + contactsRes := QueryContractResponse{} + if err := json.Unmarshal([]byte(stdout), &contactsRes); err != nil { + return "", err + } + + contractAddress := contactsRes.Contracts[len(contactsRes.Contracts)-1] + return contractAddress, nil +} + +func (tn *ChainNode) ExecuteContract(ctx context.Context, keyName string, contractAddress string, message string) error { + command := []string{tn.Chain.Config().Bin, + "tx", "wasm", "execute", contractAddress, message, + "--from", keyName, + "--gas-prices", tn.Chain.Config().GasPrices, + "--gas-adjustment", fmt.Sprint(tn.Chain.Config().GasAdjustment), + "--keyring-backend", keyring.BackendTest, + "--node", fmt.Sprintf("tcp://%s:26657", tn.Name()), + "--output", "json", + "-y", + "--home", tn.NodeHome(), + "--chain-id", tn.Chain.Config().ChainID, + } + return handleNodeJobError(tn.NodeJob(ctx, command)) +} + +type ContractStateModels struct { + Key string `json:"key"` + Value string `json:"value"` +} + +type DumpContractStateResponse struct { + Models []ContractStateModels `json:"models"` +} + +func (tn *ChainNode) DumpContractState(ctx context.Context, contractAddress string, height int64) (*DumpContractStateResponse, error) { + command := []string{tn.Chain.Config().Bin, + "query", "wasm", "contract-state", "all", contractAddress, + "--height", fmt.Sprint(height), + "--node", fmt.Sprintf("tcp://%s:26657", tn.Name()), + "--output", "json", + "--home", tn.NodeHome(), + "--chain-id", tn.Chain.Config().ChainID, + } + exitCode, stdout, stderr, err := tn.NodeJob(ctx, command) + if err != nil { + return nil, handleNodeJobError(exitCode, stdout, stderr, err) + } + + res := &DumpContractStateResponse{} + if err := json.Unmarshal([]byte(stdout), res); err != nil { + return nil, err + } + return res, nil +} + +func (tn *ChainNode) ExportState(ctx context.Context, height int64) (string, error) { + command := []string{tn.Chain.Config().Bin, + "export", + "--height", fmt.Sprint(height), + "--home", tn.NodeHome(), + } + + exitCode, stdout, stderr, err := tn.NodeJob(ctx, command) + if err != nil { + return "", handleNodeJobError(exitCode, stdout, stderr, err) + } + // output comes to stderr for some reason + return stderr, nil +} + +func (tn *ChainNode) UnsafeResetAll(ctx context.Context) error { + command := []string{tn.Chain.Config().Bin, + "unsafe-reset-all", + "--home", tn.NodeHome(), + } + + return handleNodeJobError(tn.NodeJob(ctx, command)) +} + +func (tn *ChainNode) CreatePool(ctx context.Context, keyName string, contractAddress string, swapFee float64, exitFee float64, assets []WalletAmount) error { + // TODO generate --pool-file + poolFilePath := "TODO" + command := []string{tn.Chain.Config().Bin, + "tx", "gamm", "create-pool", + "--pool-file", poolFilePath, + "--gas-prices", tn.Chain.Config().GasPrices, + "--gas-adjustment", fmt.Sprint(tn.Chain.Config().GasAdjustment), + "--from", keyName, + "--keyring-backend", keyring.BackendTest, + "--node", fmt.Sprintf("tcp://%s:26657", tn.Name()), + "--output", "json", + "-y", + "--home", tn.NodeHome(), + "--chain-id", tn.Chain.Config().ChainID, + } + return handleNodeJobError(tn.NodeJob(ctx, command)) +} + func (tn *ChainNode) CreateNodeContainer() error { chainCfg := tn.Chain.Config() - cmd := []string{chainCfg.Bin, "start", "--home", tn.NodeHome()} + cmd := []string{chainCfg.Bin, "start", "--home", tn.NodeHome(), "--x-crisis-skip-assert-invariants"} fmt.Printf("{%s} -> '%s'\n", tn.Name(), strings.Join(cmd, " ")) + cont, err := tn.Pool.Client.CreateContainer(docker.CreateContainerOptions{ Name: tn.Name(), Config: &docker.Config{ @@ -548,7 +830,7 @@ func (tn *ChainNode) NodeJob(ctx context.Context, cmd []string) (int, string, st exitCode, err := tn.Pool.Client.WaitContainerWithContext(cont.ID, ctx) stdout := new(bytes.Buffer) stderr := new(bytes.Buffer) - _ = tn.Pool.Client.Logs(docker.LogsOptions{Context: ctx, Container: cont.ID, OutputStream: stdout, ErrorStream: stderr, Stdout: true, Stderr: true, Tail: "100", Follow: false, Timestamps: false}) + _ = tn.Pool.Client.Logs(docker.LogsOptions{Context: ctx, Container: cont.ID, OutputStream: stdout, ErrorStream: stderr, Stdout: true, Stderr: true, Tail: "50", Follow: false, Timestamps: false}) _ = tn.Pool.Client.RemoveContainer(docker.RemoveContainerOptions{ID: cont.ID}) fmt.Printf("{%s} - stdout:\n%s\n{%s} - stderr:\n%s\n", container, stdout.String(), container, stderr.String()) return exitCode, stdout.String(), stderr.String(), err diff --git a/ibc/test_relay.go b/ibc/test_relay.go index 9963dda5e..5e18547f5 100644 --- a/ibc/test_relay.go +++ b/ibc/test_relay.go @@ -1,39 +1,12 @@ package ibc import ( - "errors" "fmt" - "reflect" "time" transfertypes "github.com/cosmos/ibc-go/v3/modules/apps/transfer/types" ) -// all methods on this struct have the same signature and are method names that will be called by the CLI -type IBCTestCase struct{} - -// uses reflection to get test case -func GetTestCase(testCase string) (func(testName string, srcChain Chain, dstChain Chain, relayerImplementation RelayerImplementation) error, error) { - v := reflect.ValueOf(IBCTestCase{}) - m := v.MethodByName(testCase) - if m.Kind() != reflect.Func { - return nil, fmt.Errorf("invalid test case: %s", testCase) - } - - testCaseFunc := func(testName string, srcChain Chain, dstChain Chain, relayerImplementation RelayerImplementation) error { - args := []reflect.Value{reflect.ValueOf(testName), reflect.ValueOf(srcChain), reflect.ValueOf(dstChain), reflect.ValueOf(relayerImplementation)} - result := m.Call(args) - if len(result) != 1 || !result[0].CanInterface() { - return errors.New("error reflecting error return var") - } - - err, _ := result[0].Interface().(error) - return err - } - - return testCaseFunc, nil -} - func (ibc IBCTestCase) RelayPacketTest(testName string, srcChain Chain, dstChain Chain, relayerImplementation RelayerImplementation) error { ctx, home, pool, network, cleanup, err := SetupTestRun(testName) if err != nil { @@ -46,7 +19,7 @@ func (ibc IBCTestCase) RelayPacketTest(testName string, srcChain Chain, dstChain // funds relayer src and dst wallets on respective chain in genesis // creates a user account on the src chain (separate fullnode) // funds user account on src chain in genesis - channels, user, rlyCleanup, err := StartChainsAndRelayer(testName, ctx, pool, network, home, srcChain, dstChain, relayerImplementation, nil) + _, channels, srcUser, dstUser, rlyCleanup, err := StartChainsAndRelayer(testName, ctx, pool, network, home, srcChain, dstChain, relayerImplementation, nil) if err != nil { return err } @@ -54,34 +27,34 @@ func (ibc IBCTestCase) RelayPacketTest(testName string, srcChain Chain, dstChain // will test a user sending an ibc transfer from the src chain to the dst chain // denom will be src chain native denom - testDenom := srcChain.Config().Denom + testDenomSrc := srcChain.Config().Denom // query initial balance of user wallet for src chain native denom on the src chain - srcInitialBalance, err := srcChain.GetBalance(ctx, user.SrcChainAddress, testDenom) + srcInitialBalance, err := srcChain.GetBalance(ctx, srcUser.SrcChainAddress, testDenomSrc) if err != nil { return err } // get ibc denom for test denom on dst chain - denomTrace := transfertypes.ParseDenomTrace(transfertypes.GetPrefixedDenom(channels[0].Counterparty.PortID, channels[0].Counterparty.ChannelID, testDenom)) + denomTrace := transfertypes.ParseDenomTrace(transfertypes.GetPrefixedDenom(channels[0].Counterparty.PortID, channels[0].Counterparty.ChannelID, testDenomSrc)) dstIbcDenom := denomTrace.IBCDenom() // query initial balance of user wallet for src chain native denom on the dst chain // don't care about error here, account does not exist on destination chain - dstInitialBalance, _ := dstChain.GetBalance(ctx, user.DstChainAddress, dstIbcDenom) + dstInitialBalance, _ := dstChain.GetBalance(ctx, srcUser.DstChainAddress, dstIbcDenom) fmt.Printf("Initial balances: Src chain: %d\nDst chain: %d\n", srcInitialBalance, dstInitialBalance) // test coin, address is recipient of ibc transfer on dst chain - testCoin := WalletAmount{ - Address: user.DstChainAddress, - Denom: testDenom, + testCoinSrc := WalletAmount{ + Address: srcUser.DstChainAddress, + Denom: testDenomSrc, Amount: 1000000, } // send ibc transfer from the user wallet using its fullnode // timeout is nil so that it will use the default timeout - txHash, err := srcChain.SendIBCTransfer(ctx, channels[0].ChannelID, user.KeyName, testCoin, nil) + srcTxHash, err := srcChain.SendIBCTransfer(ctx, channels[0].ChannelID, srcUser.KeyName, testCoinSrc, nil) if err != nil { return err } @@ -92,31 +65,106 @@ func (ibc IBCTestCase) RelayPacketTest(testName string, srcChain Chain, dstChain } // fetch ibc transfer tx - srcTx, err := srcChain.GetTransaction(ctx, txHash) + srcTx, err := srcChain.GetTransaction(ctx, srcTxHash) if err != nil { return err } fmt.Printf("Transaction:\n%v\n", srcTx) - // query final balance of user wallet for src chain native denom on the src chain - srcFinalBalance, err := srcChain.GetBalance(ctx, user.SrcChainAddress, testDenom) + // query final balance of src user wallet for src chain native denom on the src chain + srcFinalBalance, err := srcChain.GetBalance(ctx, srcUser.SrcChainAddress, testDenomSrc) if err != nil { return err } - // query final balance of user wallet for src chain native denom on the dst chain - dstFinalBalance, err := dstChain.GetBalance(ctx, user.DstChainAddress, dstIbcDenom) + // query final balance of src user wallet for src chain native denom on the dst chain + dstFinalBalance, err := dstChain.GetBalance(ctx, srcUser.DstChainAddress, dstIbcDenom) if err != nil { return err } - if srcFinalBalance != srcInitialBalance-testCoin.Amount { - return fmt.Errorf("source balances do not match. expected: %d, actual: %d", srcInitialBalance-testCoin.Amount, srcFinalBalance) + totalFees := srcChain.GetGasFeesInNativeDenom(srcTx.GasWanted) + expectedDifference := testCoinSrc.Amount + totalFees + + if srcFinalBalance != srcInitialBalance-expectedDifference { + return fmt.Errorf("source balances do not match. expected: %d, actual: %d", srcInitialBalance-expectedDifference, srcFinalBalance) } - if dstFinalBalance != dstInitialBalance+testCoin.Amount { - return fmt.Errorf("destination balances do not match. expected: %d, actual: %d", dstInitialBalance+testCoin.Amount, dstFinalBalance) + if dstFinalBalance != dstInitialBalance+testCoinSrc.Amount { + return fmt.Errorf("destination balances do not match. expected: %d, actual: %d", dstInitialBalance+testCoinSrc.Amount, dstFinalBalance) + } + + // Now relay from dst chain to src chain using dst user wallet + + // will test a user sending an ibc transfer from the dst chain to the src chain + // denom will be dst chain native denom + testDenomDst := dstChain.Config().Denom + + // query initial balance of dst user wallet for dst chain native denom on the dst chain + dstInitialBalance, err = dstChain.GetBalance(ctx, dstUser.DstChainAddress, testDenomDst) + if err != nil { + return err + } + + // get ibc denom for test denom on src chain + srcDenomTrace := transfertypes.ParseDenomTrace(transfertypes.GetPrefixedDenom(channels[0].PortID, channels[0].ChannelID, testDenomDst)) + srcIbcDenom := srcDenomTrace.IBCDenom() + + // query initial balance of user wallet for src chain native denom on the dst chain + // don't care about error here, account does not exist on destination chain + srcInitialBalance, _ = srcChain.GetBalance(ctx, dstUser.SrcChainAddress, srcIbcDenom) + + fmt.Printf("Initial balances: Src chain: %d\nDst chain: %d\n", srcInitialBalance, dstInitialBalance) + + // test coin, address is recipient of ibc transfer on src chain + testCoinDst := WalletAmount{ + Address: dstUser.SrcChainAddress, + Denom: testDenomDst, + Amount: 1000000, + } + + // send ibc transfer from the dst user wallet using its fullnode + // timeout is nil so that it will use the default timeout + dstTxHash, err := dstChain.SendIBCTransfer(ctx, channels[0].Counterparty.ChannelID, dstUser.KeyName, testCoinDst, nil) + if err != nil { + return err + } + + // wait for both chains to produce 10 blocks + if err := WaitForBlocks(srcChain, dstChain, 10); err != nil { + return err + } + + // fetch ibc transfer tx + dstTx, err := dstChain.GetTransaction(ctx, dstTxHash) + if err != nil { + return err + } + + fmt.Printf("Transaction:\n%v\n", dstTx) + + // query final balance of dst user wallet for dst chain native denom on the dst chain + dstFinalBalance, err = dstChain.GetBalance(ctx, dstUser.DstChainAddress, testDenomDst) + if err != nil { + return err + } + + // query final balance of dst user wallet for dst chain native denom on the src chain + srcFinalBalance, err = srcChain.GetBalance(ctx, dstUser.SrcChainAddress, srcIbcDenom) + if err != nil { + return err + } + + totalFeesDst := dstChain.GetGasFeesInNativeDenom(dstTx.GasWanted) + expectedDifference = testCoinDst.Amount + totalFeesDst + + if dstFinalBalance != dstInitialBalance-expectedDifference { + return fmt.Errorf("destination balances do not match. expected: %d, actual: %d", dstInitialBalance-expectedDifference, dstFinalBalance) + } + + if srcFinalBalance != srcInitialBalance+testCoinDst.Amount { + return fmt.Errorf("source balances do not match. expected: %d, actual: %d", srcInitialBalance+testCoinDst.Amount, srcFinalBalance) } return nil @@ -137,9 +185,9 @@ func (ibc IBCTestCase) RelayPacketTestNoTimeout(testName string, srcChain Chain, var testCoin WalletAmount // Query user account balances on both chains and send IBC transfer before starting the relayer - preRelayerStart := func(channels []ChannelOutput, user User) error { + preRelayerStart := func(channels []ChannelOutput, srcUser User, dstUser User) error { var err error - srcInitialBalance, err = srcChain.GetBalance(ctx, user.SrcChainAddress, testDenom) + srcInitialBalance, err = srcChain.GetBalance(ctx, srcUser.SrcChainAddress, testDenom) if err != nil { return err } @@ -149,23 +197,23 @@ func (ibc IBCTestCase) RelayPacketTestNoTimeout(testName string, srcChain Chain, dstIbcDenom = denomTrace.IBCDenom() // don't care about error here, account does not exist on destination chain - dstInitialBalance, _ = dstChain.GetBalance(ctx, user.DstChainAddress, dstIbcDenom) + dstInitialBalance, _ = dstChain.GetBalance(ctx, srcUser.DstChainAddress, dstIbcDenom) fmt.Printf("Initial balances: Src chain: %d\nDst chain: %d\n", srcInitialBalance, dstInitialBalance) testCoin = WalletAmount{ - Address: user.DstChainAddress, + Address: srcUser.DstChainAddress, Denom: testDenom, Amount: 1000000, } // send ibc transfer with both timeouts disabled - txHash, err = srcChain.SendIBCTransfer(ctx, channels[0].ChannelID, user.KeyName, testCoin, &IBCTimeout{Height: 0, NanoSeconds: 0}) + txHash, err = srcChain.SendIBCTransfer(ctx, channels[0].ChannelID, srcUser.KeyName, testCoin, &IBCTimeout{Height: 0, NanoSeconds: 0}) return err } // Startup both chains and relayer - _, user, rlyCleanup, err := StartChainsAndRelayer(testName, ctx, pool, network, home, srcChain, dstChain, relayerImplementation, preRelayerStart) + _, _, user, _, rlyCleanup, err := StartChainsAndRelayer(testName, ctx, pool, network, home, srcChain, dstChain, relayerImplementation, preRelayerStart) if err != nil { return err } @@ -194,8 +242,11 @@ func (ibc IBCTestCase) RelayPacketTestNoTimeout(testName string, srcChain Chain, return err } - if srcFinalBalance != srcInitialBalance-testCoin.Amount { - return fmt.Errorf("source balances do not match. expected: %d, actual: %d", srcInitialBalance-testCoin.Amount, srcFinalBalance) + totalFees := srcChain.GetGasFeesInNativeDenom(srcTx.GasWanted) + expectedDifference := testCoin.Amount + totalFees + + if srcFinalBalance != srcInitialBalance-expectedDifference { + return fmt.Errorf("source balances do not match. expected: %d, actual: %d", srcInitialBalance-expectedDifference, srcFinalBalance) } if dstFinalBalance != dstInitialBalance+testCoin.Amount { @@ -219,9 +270,9 @@ func (ibc IBCTestCase) RelayPacketTestHeightTimeout(testName string, srcChain Ch var dstIbcDenom string // Query user account balances on both chains and send IBC transfer before starting the relayer - preRelayerStart := func(channels []ChannelOutput, user User) error { + preRelayerStart := func(channels []ChannelOutput, srcUser User, dstUser User) error { var err error - srcInitialBalance, err = srcChain.GetBalance(ctx, user.SrcChainAddress, testDenom) + srcInitialBalance, err = srcChain.GetBalance(ctx, srcUser.SrcChainAddress, testDenom) if err != nil { return err } @@ -231,28 +282,29 @@ func (ibc IBCTestCase) RelayPacketTestHeightTimeout(testName string, srcChain Ch dstIbcDenom = denomTrace.IBCDenom() // don't care about error here, account does not exist on destination chain - dstInitialBalance, _ = dstChain.GetBalance(ctx, user.DstChainAddress, dstIbcDenom) + dstInitialBalance, _ = dstChain.GetBalance(ctx, srcUser.DstChainAddress, dstIbcDenom) fmt.Printf("Initial balances: Src chain: %d\nDst chain: %d\n", srcInitialBalance, dstInitialBalance) testCoin := WalletAmount{ - Address: user.DstChainAddress, + Address: srcUser.DstChainAddress, Denom: testDenom, Amount: 1000000, } // send ibc transfer with a timeout of 10 blocks from now on counterparty chain - txHash, err = srcChain.SendIBCTransfer(ctx, channels[0].ChannelID, user.KeyName, testCoin, &IBCTimeout{Height: 10}) + txHash, err = srcChain.SendIBCTransfer(ctx, channels[0].ChannelID, srcUser.KeyName, testCoin, &IBCTimeout{Height: 10}) if err != nil { return err } // wait until counterparty chain has passed the timeout - return dstChain.WaitForBlocks(11) + _, err = dstChain.WaitForBlocks(11) + return err } // Startup both chains and relayer - _, user, rlyCleanup, err := StartChainsAndRelayer(testName, ctx, pool, network, home, srcChain, dstChain, relayerImplementation, preRelayerStart) + _, _, user, _, rlyCleanup, err := StartChainsAndRelayer(testName, ctx, pool, network, home, srcChain, dstChain, relayerImplementation, preRelayerStart) if err != nil { return err } @@ -281,8 +333,10 @@ func (ibc IBCTestCase) RelayPacketTestHeightTimeout(testName string, srcChain Ch return err } - if srcFinalBalance != srcInitialBalance { - return fmt.Errorf("source balances do not match. expected: %d, actual: %d", srcInitialBalance, srcFinalBalance) + totalFees := srcChain.GetGasFeesInNativeDenom(srcTx.GasWanted) + + if srcFinalBalance != srcInitialBalance-totalFees { + return fmt.Errorf("source balances do not match. expected: %d, actual: %d", srcInitialBalance-totalFees, srcFinalBalance) } if dstFinalBalance != dstInitialBalance { @@ -307,9 +361,9 @@ func (ibc IBCTestCase) RelayPacketTestTimestampTimeout(testName string, srcChain var dstIbcDenom string // Query user account balances on both chains and send IBC transfer before starting the relayer - preRelayerStart := func(channels []ChannelOutput, user User) error { + preRelayerStart := func(channels []ChannelOutput, srcUser User, dstUser User) error { var err error - srcInitialBalance, err = srcChain.GetBalance(ctx, user.SrcChainAddress, testDenom) + srcInitialBalance, err = srcChain.GetBalance(ctx, srcUser.SrcChainAddress, testDenom) if err != nil { return err } @@ -319,18 +373,18 @@ func (ibc IBCTestCase) RelayPacketTestTimestampTimeout(testName string, srcChain dstIbcDenom = denomTrace.IBCDenom() // don't care about error here, account does not exist on destination chain - dstInitialBalance, _ = dstChain.GetBalance(ctx, user.DstChainAddress, dstIbcDenom) + dstInitialBalance, _ = dstChain.GetBalance(ctx, srcUser.DstChainAddress, dstIbcDenom) fmt.Printf("Initial balances: Src chain: %d\nDst chain: %d\n", srcInitialBalance, dstInitialBalance) testCoin := WalletAmount{ - Address: user.DstChainAddress, + Address: srcUser.DstChainAddress, Denom: testDenom, Amount: 1000000, } // send ibc transfer with a timeout of 10 blocks from now on counterparty chain - txHash, err = srcChain.SendIBCTransfer(ctx, channels[0].ChannelID, user.KeyName, testCoin, &IBCTimeout{NanoSeconds: uint64((10 * time.Second).Nanoseconds())}) + txHash, err = srcChain.SendIBCTransfer(ctx, channels[0].ChannelID, srcUser.KeyName, testCoin, &IBCTimeout{NanoSeconds: uint64((10 * time.Second).Nanoseconds())}) if err != nil { return err } @@ -342,7 +396,7 @@ func (ibc IBCTestCase) RelayPacketTestTimestampTimeout(testName string, srcChain } // Startup both chains and relayer - _, user, rlyCleanup, err := StartChainsAndRelayer(testName, ctx, pool, network, home, srcChain, dstChain, relayerImplementation, preRelayerStart) + _, _, user, _, rlyCleanup, err := StartChainsAndRelayer(testName, ctx, pool, network, home, srcChain, dstChain, relayerImplementation, preRelayerStart) if err != nil { return err } @@ -371,8 +425,10 @@ func (ibc IBCTestCase) RelayPacketTestTimestampTimeout(testName string, srcChain return err } - if srcFinalBalance != srcInitialBalance { - return fmt.Errorf("source balances do not match. expected: %d, actual: %d", srcInitialBalance, srcFinalBalance) + totalFees := srcChain.GetGasFeesInNativeDenom(srcTx.GasWanted) + + if srcFinalBalance != srcInitialBalance-totalFees { + return fmt.Errorf("source balances do not match. expected: %d, actual: %d", srcInitialBalance-totalFees, srcFinalBalance) } if dstFinalBalance != dstInitialBalance { diff --git a/ibc/test_setup.go b/ibc/test_setup.go index 7f54790d4..371c2138f 100644 --- a/ibc/test_setup.go +++ b/ibc/test_setup.go @@ -4,11 +4,13 @@ import ( "bytes" "context" "crypto/rand" + "errors" "fmt" "io/ioutil" "math/big" "net" "os" + "reflect" "strings" "time" @@ -32,6 +34,33 @@ const ( testPathName = "test-path" ) +// all methods on this struct have the same signature and are method names that will be called by the CLI +// func (ibc IBCTestCase) TestCaseName(testName string, srcChain Chain, dstChain Chain, relayerImplementation RelayerImplementation) error +type IBCTestCase struct{} + +// uses reflection to get test case +func GetTestCase(testCase string) (func(testName string, srcChain Chain, dstChain Chain, relayerImplementation RelayerImplementation) error, error) { + v := reflect.ValueOf(IBCTestCase{}) + m := v.MethodByName(testCase) + + if m.Kind() != reflect.Func { + return nil, fmt.Errorf("invalid test case: %s", testCase) + } + + testCaseFunc := func(testName string, srcChain Chain, dstChain Chain, relayerImplementation RelayerImplementation) error { + args := []reflect.Value{reflect.ValueOf(testName), reflect.ValueOf(srcChain), reflect.ValueOf(dstChain), reflect.ValueOf(relayerImplementation)} + result := m.Call(args) + if len(result) != 1 || !result[0].CanInterface() { + return errors.New("error reflecting error return var") + } + + err, _ := result[0].Interface().(error) + return err + } + + return testCaseFunc, nil +} + // RandLowerCaseLetterString returns a lowercase letter string of given length func RandLowerCaseLetterString(length int) string { chars := []rune("abcdefghijklmnopqrstuvwxyz") @@ -83,8 +112,8 @@ func StartChainsAndRelayer( srcChain Chain, dstChain Chain, relayerImplementation RelayerImplementation, - preRelayerStart func(channels []ChannelOutput, user User) error, -) ([]ChannelOutput, User, func(), error) { + preRelayerStart func([]ChannelOutput, User, User) error, +) (Relayer, []ChannelOutput, *User, *User, func(), error) { var relayerImpl Relayer switch relayerImplementation { case CosmosRly: @@ -100,8 +129,8 @@ func StartChainsAndRelayer( // not yet supported } - errResponse := func(err error) ([]ChannelOutput, User, func(), error) { - return []ChannelOutput{}, User{}, nil, err + errResponse := func(err error) (Relayer, []ChannelOutput, *User, *User, func(), error) { + return nil, []ChannelOutput{}, nil, nil, nil, err } if err := srcChain.Initialize(testName, home, pool, networkID); err != nil { @@ -141,14 +170,14 @@ func StartChainsAndRelayer( } // Fund relayer account on src chain - srcWallet := WalletAmount{ + srcRelayerWalletAmount := WalletAmount{ Address: srcAccount, Denom: srcChainCfg.Denom, Amount: 10000000, } // Fund relayer account on dst chain - dstWallet := WalletAmount{ + dstRelayerWalletAmount := WalletAmount{ Address: dstAccount, Denom: dstChainCfg.Denom, Amount: 10000000, @@ -158,41 +187,74 @@ func StartChainsAndRelayer( if err := srcChain.CreateKey(ctx, userAccountKeyName); err != nil { return errResponse(err) } - userAccountAddressBytes, err := srcChain.GetAddress(userAccountKeyName) + + srcUserAccountAddressBytes, err := srcChain.GetAddress(userAccountKeyName) + if err != nil { + return errResponse(err) + } + + srcUserAccountSrc, err := types.Bech32ifyAddressBytes(srcChainCfg.Bech32Prefix, srcUserAccountAddressBytes) + if err != nil { + return errResponse(err) + } + + srcUserAccountDst, err := types.Bech32ifyAddressBytes(dstChainCfg.Bech32Prefix, srcUserAccountAddressBytes) + if err != nil { + return errResponse(err) + } + + if err := dstChain.CreateKey(ctx, userAccountKeyName); err != nil { + return errResponse(err) + } + + dstUserAccountAddressBytes, err := dstChain.GetAddress(userAccountKeyName) if err != nil { return errResponse(err) } - userAccountSrc, err := types.Bech32ifyAddressBytes(srcChainCfg.Bech32Prefix, userAccountAddressBytes) + dstUserAccountSrc, err := types.Bech32ifyAddressBytes(srcChainCfg.Bech32Prefix, dstUserAccountAddressBytes) if err != nil { return errResponse(err) } - userAccountDst, err := types.Bech32ifyAddressBytes(dstChainCfg.Bech32Prefix, userAccountAddressBytes) + dstUserAccountDst, err := types.Bech32ifyAddressBytes(dstChainCfg.Bech32Prefix, dstUserAccountAddressBytes) if err != nil { return errResponse(err) } - user := User{ + srcUser := User{ + KeyName: userAccountKeyName, + SrcChainAddress: srcUserAccountSrc, + DstChainAddress: srcUserAccountDst, + } + + dstUser := User{ KeyName: userAccountKeyName, - SrcChainAddress: userAccountSrc, - DstChainAddress: userAccountDst, + SrcChainAddress: dstUserAccountSrc, + DstChainAddress: dstUserAccountDst, } // Fund user account on src chain in order to relay from src to dst - userWalletSrc := WalletAmount{ - Address: userAccountSrc, + srcUserWalletAmount := WalletAmount{ + Address: srcUserAccountSrc, Denom: srcChainCfg.Denom, - Amount: 100000000, + Amount: 10000000000, + } + + // Fund user account on dst chain in order to relay from dst to src + dstUserWalletAmount := WalletAmount{ + Address: dstUserAccountDst, + Denom: dstChainCfg.Denom, + Amount: 10000000000, } // start chains from genesis, wait until they are producing blocks chainsGenesisWaitGroup := errgroup.Group{} chainsGenesisWaitGroup.Go(func() error { - return srcChain.Start(testName, ctx, []WalletAmount{srcWallet, userWalletSrc}) + return srcChain.Start(testName, ctx, []WalletAmount{srcRelayerWalletAmount, srcUserWalletAmount}) }) chainsGenesisWaitGroup.Go(func() error { - return dstChain.Start(testName, ctx, []WalletAmount{dstWallet}) + return dstChain.Start(testName, ctx, []WalletAmount{dstRelayerWalletAmount, dstUserWalletAmount}) }) if err := chainsGenesisWaitGroup.Wait(); err != nil { @@ -212,7 +274,7 @@ func StartChainsAndRelayer( } if preRelayerStart != nil { - if err := preRelayerStart(channels, user); err != nil { + if err := preRelayerStart(channels, srcUser, dstUser); err != nil { return errResponse(err) } } @@ -231,16 +293,18 @@ func StartChainsAndRelayer( } } - return channels, user, relayerCleanup, nil + return relayerImpl, channels, &srcUser, &dstUser, relayerCleanup, nil } func WaitForBlocks(srcChain Chain, dstChain Chain, blocksToWait int64) error { chainsConsecutiveBlocksWaitGroup := errgroup.Group{} - chainsConsecutiveBlocksWaitGroup.Go(func() error { - return srcChain.WaitForBlocks(blocksToWait) + chainsConsecutiveBlocksWaitGroup.Go(func() (err error) { + _, err = srcChain.WaitForBlocks(blocksToWait) + return }) - chainsConsecutiveBlocksWaitGroup.Go(func() error { - return dstChain.WaitForBlocks(blocksToWait) + chainsConsecutiveBlocksWaitGroup.Go(func() (err error) { + _, err = dstChain.WaitForBlocks(blocksToWait) + return }) return chainsConsecutiveBlocksWaitGroup.Wait() } @@ -278,6 +342,7 @@ func CreateTestNetwork(pool *dockertest.Pool, name string, testName string) (*do // Cleanup will clean up Docker containers, networks, and the other various config files generated in testing func Cleanup(testName string, pool *dockertest.Pool, testDir string) func() { return func() { + showContainerLogs := os.Getenv("SHOW_CONTAINER_LOGS") cont, _ := pool.Client.ListContainers(docker.ListContainersOptions{All: true}) ctx := context.Background() for _, c := range cont { @@ -289,7 +354,9 @@ func Cleanup(testName string, pool *dockertest.Pool, testDir string) func() { stderr := new(bytes.Buffer) _ = pool.Client.Logs(docker.LogsOptions{Context: ctx, Container: c.ID, OutputStream: stdout, ErrorStream: stderr, Stdout: true, Stderr: true, Tail: "50", Follow: false, Timestamps: false}) names := strings.Join(c.Names, ",") - fmt.Printf("{%s} - stdout:\n%s\n{%s} - stderr:\n%s\n", names, stdout, names, stderr) + if showContainerLogs != "" { + fmt.Printf("{%s} - stdout:\n%s\n{%s} - stderr:\n%s\n", names, stdout, names, stderr) + } _ = pool.Client.RemoveContainer(docker.RemoveContainerOptions{ID: c.ID}) } } diff --git a/trophies/test_juno.go b/trophies/test_juno.go new file mode 100644 index 000000000..c26ca2865 --- /dev/null +++ b/trophies/test_juno.go @@ -0,0 +1,410 @@ +//go:build exclude + +import ( + "encoding/base64" + "fmt" + "io/ioutil" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/cosmos/cosmos-sdk/types" + transfertypes "github.com/cosmos/ibc-go/v3/modules/apps/transfer/types" + "github.com/ory/dockertest/docker" +) + +func (ibc IBCTestCase) JunoHaltTest(testName string, srcChain Chain, dstChain Chain, relayerImplementation RelayerImplementation) error { + ctx, home, pool, network, cleanup, err := SetupTestRun(testName) + if err != nil { + return err + } + defer cleanup() + + if err := srcChain.Initialize(testName, home, pool, network); err != nil { + return err + } + + srcChainCfg := srcChain.Config() + + // Generate key to be used for "user" that will execute transactions + if err := srcChain.CreateKey(ctx, userAccountKeyName); err != nil { + return err + } + + userAccountAddressBytes, err := srcChain.GetAddress(userAccountKeyName) + if err != nil { + return err + } + + userAccountSrc, err := types.Bech32ifyAddressBytes(srcChainCfg.Bech32Prefix, userAccountAddressBytes) + if err != nil { + return err + } + + // Fund user account on src chain that will be used to instantiate and execute contract + userWalletSrc := WalletAmount{ + Address: userAccountSrc, + Denom: srcChainCfg.Denom, + Amount: 100000000000, + } + + if err := srcChain.Start(testName, ctx, []WalletAmount{userWalletSrc}); err != nil { + return err + } + + executablePath, err := os.Executable() + if err != nil { + return err + } + rootPath := filepath.Dir(executablePath) + contractPath := path.Join(rootPath, "assets", "badcontract.wasm") + + contractAddress, err := srcChain.InstantiateContract(ctx, userAccountKeyName, WalletAmount{Amount: 100, Denom: srcChain.Config().Denom}, contractPath, "{\"count\":0}", srcChainCfg.Version == "v2.3.0") + if err != nil { + return err + } + + resets := []int{0, 15, 84, 0, 84, 42, 55, 42, 15, 84, 42} + + for _, resetCount := range resets { + // run reset + if err := srcChain.ExecuteContract(ctx, userAccountKeyName, contractAddress, fmt.Sprintf("{\"reset\":{\"count\": %d}}", resetCount)); err != nil { + return err + } + latestHeight, err := srcChain.WaitForBlocks(5) + if err != nil { + return err + } + + // dump current contract state + res, err := srcChain.DumpContractState(ctx, contractAddress, latestHeight) + if err != nil { + return err + } + contractData, err := base64.StdEncoding.DecodeString(res.Models[1].Value) + if err != nil { + return err + } + fmt.Printf("Contract data: %s\n", contractData) + + // run increment a bunch of times + for i := 0; i < 5; i++ { + if err := srcChain.ExecuteContract(ctx, userAccountKeyName, contractAddress, "{\"increment\":{}}"); err != nil { + return err + } + if _, err := srcChain.WaitForBlocks(1); err != nil { + return err + } + } + } + + return nil +} + +func (ibc IBCTestCase) JunoPostHaltGenesis(testName string, srcChain Chain, dstChain Chain, relayerImplementation RelayerImplementation) error { + ctx, home, pool, network, cleanup, err := SetupTestRun(testName) + if err != nil { + return err + } + defer cleanup() + + if err := srcChain.Initialize(testName, home, pool, network); err != nil { + return err + } + + executablePath, err := os.Executable() + if err != nil { + return err + } + rootPath := filepath.Dir(executablePath) + genesisFilePath := path.Join(rootPath, "assets", "juno-1-96.json") + + if err := srcChain.StartWithGenesisFile(testName, ctx, home, pool, network, genesisFilePath); err != nil { + return err + } + + _, err = srcChain.WaitForBlocks(20) + return err +} + +func (ibc IBCTestCase) JunoHaltNewGenesis(testName string, _ Chain, _ Chain, relayerImplementation RelayerImplementation) error { + ctx, home, pool, network, cleanup, err := SetupTestRun(testName) + if err != nil { + return err + } + defer cleanup() + + // overriding input vars + srcChain, err := GetChain(testName, "juno", "v2.1.0", "juno-1", 10, 1) + if err != nil { + return err + } + + dstChain, err := GetChain(testName, "osmosis", "v7.1.0", "osmosis-1", 4, 0) + if err != nil { + return err + } + + // startup both chains and relayer + // creates wallets in the relayer for src and dst chain + // funds relayer src and dst wallets on respective chain in genesis + // creates a user account on the src chain (separate fullnode) + // funds user account on src chain in genesis + relayer, channels, user, rlyCleanup, err := StartChainsAndRelayer(testName, ctx, pool, network, home, srcChain, dstChain, relayerImplementation, nil) + if err != nil { + return err + } + defer rlyCleanup() + + // will test a user sending an ibc transfer from the src chain to the dst chain + // denom will be src chain native denom + testDenom := srcChain.Config().Denom + + // query initial balance of user wallet for src chain native denom on the src chain + srcInitialBalance, err := srcChain.GetBalance(ctx, user.SrcChainAddress, testDenom) + if err != nil { + return err + } + + // get ibc denom for test denom on dst chain + denomTrace := transfertypes.ParseDenomTrace(transfertypes.GetPrefixedDenom(channels[0].Counterparty.PortID, channels[0].Counterparty.ChannelID, testDenom)) + dstIbcDenom := denomTrace.IBCDenom() + + // query initial balance of user wallet for src chain native denom on the dst chain + // don't care about error here, account does not exist on destination chain + dstInitialBalance, _ := dstChain.GetBalance(ctx, user.DstChainAddress, dstIbcDenom) + + fmt.Printf("Initial balances: Src chain: %d\nDst chain: %d\n", srcInitialBalance, dstInitialBalance) + + // test coin, address is recipient of ibc transfer on dst chain + testCoin := WalletAmount{ + Address: user.DstChainAddress, + Denom: testDenom, + Amount: 1000000, + } + + // send ibc transfer from the user wallet using its fullnode + // timeout is nil so that it will use the default timeout + txHash, err := srcChain.SendIBCTransfer(ctx, channels[0].ChannelID, user.KeyName, testCoin, nil) + if err != nil { + return err + } + + // wait for both chains to produce 10 blocks + if err := WaitForBlocks(srcChain, dstChain, 10); err != nil { + return err + } + + // fetch ibc transfer tx + srcTx, err := srcChain.GetTransaction(ctx, txHash) + if err != nil { + return err + } + + fmt.Printf("Transaction:\n%v\n", srcTx) + + // query final balance of user wallet for src chain native denom on the src chain + srcFinalBalance, err := srcChain.GetBalance(ctx, user.SrcChainAddress, testDenom) + if err != nil { + return err + } + + // query final balance of user wallet for src chain native denom on the dst chain + dstFinalBalance, err := dstChain.GetBalance(ctx, user.DstChainAddress, dstIbcDenom) + if err != nil { + return err + } + + fmt.Printf("First balance check: Source: %d, Destination: %d\n", srcFinalBalance, dstFinalBalance) + + totalFees := srcChain.GetGasFeesInNativeDenom(srcTx.GasWanted) + expectedDifference := testCoin.Amount + totalFees + + if srcFinalBalance != srcInitialBalance-expectedDifference { + return fmt.Errorf("source balances do not match. expected: %d, actual: %d", srcInitialBalance-expectedDifference, srcFinalBalance) + } + + if dstFinalBalance != dstInitialBalance+testCoin.Amount { + return fmt.Errorf("destination balances do not match. expected: %d, actual: %d", dstInitialBalance+testCoin.Amount, dstFinalBalance) + } + + // IBC is confirmed working on 2.1.0, now use bad contract to halt chain + + executablePath, err := os.Executable() + if err != nil { + return err + } + rootPath := filepath.Dir(executablePath) + contractPath := path.Join(rootPath, "assets", "badcontract.wasm") + + contractAddress, err := srcChain.InstantiateContract(ctx, userAccountKeyName, WalletAmount{Amount: 100, Denom: srcChain.Config().Denom}, contractPath, "{\"count\":0}", false) + if err != nil { + return err + } + + resets := []int{0, 15, 84, 0, 84, 42, 55, 42, 15, 84, 42} + + for _, resetCount := range resets { + // run reset + if err := srcChain.ExecuteContract(ctx, userAccountKeyName, contractAddress, fmt.Sprintf("{\"reset\":{\"count\": %d}}", resetCount)); err != nil { + return err + } + // halt happens here on the first 42 reset + latestHeight, err := srcChain.WaitForBlocks(5) + if err != nil { + fmt.Println("Chain is halted") + break + } + + // dump current contract state + res, err := srcChain.DumpContractState(ctx, contractAddress, latestHeight) + if err != nil { + return err + } + contractData, err := base64.StdEncoding.DecodeString(res.Models[1].Value) + if err != nil { + return err + } + fmt.Printf("Contract data: %s\n", contractData) + + // run increment a bunch of times. + // Actual mainnet halt included this, but this test shows they are not necessary to cause halt + // for i := 0; i < 5; i++ { + // if err := srcChain.ExecuteContract(ctx, userAccountKeyName, contractAddress, "{\"increment\":{}}"); err != nil { + // return err + // } + // if _, err := srcChain.WaitForBlocks(1); err != nil { + // return err + // } + // } + } + + haltHeight, err := srcChain.Height() + if err != nil { + return err + } + + junoChainAsCosmosChain := srcChain.(*CosmosChain) + + // stop juno chain (2/3 consensus and user node) and relayer + for i := 3; i < len(junoChainAsCosmosChain.ChainNodes); i++ { + node := junoChainAsCosmosChain.ChainNodes[i] + if err := node.StopContainer(); err != nil { + return nil + } + _ = node.Pool.Client.RemoveContainer(docker.RemoveContainerOptions{ID: node.Container.ID}) + } + + // relayer should be stopped by now, but just in case + _ = relayer.StopRelayer(ctx) + + // export state from first validator + newGenesisJson, err := srcChain.ExportState(ctx, haltHeight) + if err != nil { + return err + } + + fmt.Printf("New genesis json: %s\n", newGenesisJson) + + newGenesisJson = strings.ReplaceAll(newGenesisJson, fmt.Sprintf("\"initial_height\":%d", 0), fmt.Sprintf("\"initial_height\":%d", haltHeight+2)) + + juno3Chain, err := GetChain(testName, "juno", "v3.0.0", "juno-1", 10, 1) + if err != nil { + return err + } + + // write modified genesis file to 2/3 vals and fullnode + for i := 3; i < len(junoChainAsCosmosChain.ChainNodes); i++ { + if err := junoChainAsCosmosChain.ChainNodes[i].UnsafeResetAll(ctx); err != nil { + return err + } + if err := ioutil.WriteFile(junoChainAsCosmosChain.ChainNodes[i].GenesisFilePath(), []byte(newGenesisJson), 0644); err != nil { + return err + } + junoChainAsCosmosChain.ChainNodes[i].Chain = juno3Chain + if err := junoChainAsCosmosChain.ChainNodes[i].UnsafeResetAll(ctx); err != nil { + return err + } + } + + if err := junoChainAsCosmosChain.ChainNodes.LogGenesisHashes(); err != nil { + return err + } + + for i := 3; i < len(junoChainAsCosmosChain.ChainNodes); i++ { + node := junoChainAsCosmosChain.ChainNodes[i] + if err := node.CreateNodeContainer(); err != nil { + return err + } + if err := node.StartContainer(ctx); err != nil { + return nil + } + } + + time.Sleep(1 * time.Minute) + + if _, err = srcChain.WaitForBlocks(5); err != nil { + return err + } + + // check IBC again + // note: this requires relayer version with hack to use old RPC for blocks before the halt, and new RPC for blocks after new genesis + if err = relayer.UpdateClients(ctx, testPathName); err != nil { + return err + } + + if err = relayer.StartRelayer(ctx, testPathName); err != nil { + return err + } + + // wait for relayer to start up + time.Sleep(60 * time.Second) + + // send ibc transfer from the user wallet using its fullnode + // timeout is nil so that it will use the default timeout + txHash, err = srcChain.SendIBCTransfer(ctx, channels[0].ChannelID, user.KeyName, testCoin, nil) + if err != nil { + return err + } + + // wait for both chains to produce 10 blocks + if err := WaitForBlocks(srcChain, dstChain, 10); err != nil { + return err + } + + // fetch ibc transfer tx + srcTx2, err := srcChain.GetTransaction(ctx, txHash) + if err != nil { + return err + } + + fmt.Printf("Transaction:\n%v\n", srcTx2) + + // query final balance of user wallet for src chain native denom on the src chain + srcFinalBalance2, err := srcChain.GetBalance(ctx, user.SrcChainAddress, testDenom) + if err != nil { + return err + } + + // query final balance of user wallet for src chain native denom on the dst chain + dstFinalBalance2, err := dstChain.GetBalance(ctx, user.DstChainAddress, dstIbcDenom) + if err != nil { + return err + } + + totalFees = srcChain.GetGasFeesInNativeDenom(srcTx2.GasWanted) + expectedDifference = testCoin.Amount + totalFees + + if srcFinalBalance2 != srcFinalBalance-expectedDifference { + return fmt.Errorf("source balances do not match. expected: %d, actual: %d", srcFinalBalance-expectedDifference, srcFinalBalance2) + } + + if dstFinalBalance2 != dstFinalBalance+testCoin.Amount { + return fmt.Errorf("destination balances do not match. expected: %d, actual: %d", dstFinalBalance+testCoin.Amount, dstFinalBalance2) + } + + return nil + +}