فهرست منبع

Added missing e2e tests between Terra and Ethereum/Solana (#168)

* Added missing e2e tests between Terra and Ethereum/Solana

* Review comments fixed

* Uncommented Solana<->ETH code, missing Ethereum utils file added
Yuriy Savchenko 4 سال پیش
والد
کامیت
d9bb5f6802

+ 1 - 0
DEVELOP.md

@@ -142,6 +142,7 @@ using `kubectl exec solana-devnet-0 -c setup -- cli airdrop solana-devnet:9900`
 | `0xe78A0F7E598Cc8b0Bb87894B0F60dD2a88d6a8Ab` | Wrapped asset contract                                |
 | `0xCfEB869F69431e42cdB54A4F4f105C19C080A601` | Example ERC20 token                                   |
 | `0xf5b1d8fab1054b9cf7db274126972f97f9d42a11` | Wrapped asset address for the 6qRhs8oA... SPL token   |
+| `0x62b47a23cd900da982bdbe75aeb891d3ed18cc36` | Wrapped asset address for the terra18v... Terra token |
 
 **Terra**
 

+ 2 - 10
bridge/cmd/vaa-test-terra/main.go

@@ -12,6 +12,7 @@ import (
 	"github.com/ethereum/go-ethereum/common"
 	"github.com/ethereum/go-ethereum/crypto"
 
+	"github.com/certusone/wormhole/bridge/pkg/ethereum"
 	"github.com/certusone/wormhole/bridge/pkg/vaa"
 )
 
@@ -322,14 +323,5 @@ func generateKeys(n int) (keys []*ecdsa.PrivateKey) {
 
 func hexToAddress(hex string) vaa.Address {
 	hexAddr := common.HexToAddress(hex)
-	return padAddress(hexAddr)
-}
-
-func padAddress(address common.Address) vaa.Address {
-	paddedAddress := common.LeftPadBytes(address[:], 32)
-
-	addr := vaa.Address{}
-	copy(addr[:], paddedAddress)
-
-	return addr
+	return ethereum.PadAddress(hexAddr)
 }

+ 10 - 18
bridge/cmd/vaa-test/main.go

@@ -13,6 +13,7 @@ import (
 	"github.com/ethereum/go-ethereum/crypto"
 
 	"github.com/certusone/wormhole/bridge/pkg/devnet"
+	"github.com/certusone/wormhole/bridge/pkg/ethereum"
 	"github.com/certusone/wormhole/bridge/pkg/vaa"
 )
 
@@ -39,7 +40,7 @@ func main() {
 			SourceChain:   1,
 			TargetChain:   2,
 			SourceAddress: vaa.Address{2, 1, 4},
-			TargetAddress: padAddress(devnet.GanacheClientDefaultAccountAddress),
+			TargetAddress: ethereum.PadAddress(devnet.GanacheClientDefaultAccountAddress),
 			Asset: &vaa.AssetMeta{
 				Chain:    vaa.ChainIDSolana,
 				Address:  hexToAddress("0x347ef34687bdc9f189e87a9200658d9c40e9988"),
@@ -58,7 +59,7 @@ func main() {
 			SourceChain:   1,
 			TargetChain:   2,
 			SourceAddress: vaa.Address{2, 1, 4},
-			TargetAddress: padAddress(devnet.GanacheClientDefaultAccountAddress),
+			TargetAddress: ethereum.PadAddress(devnet.GanacheClientDefaultAccountAddress),
 			Asset: &vaa.AssetMeta{
 				Chain:    vaa.ChainIDEthereum,
 				Address:  hexToAddress("0xd833215cbcc3f914bd1c9ece3ee7bf8b14f841bb"),
@@ -101,7 +102,7 @@ func main() {
 			SourceChain:   1,
 			TargetChain:   2,
 			SourceAddress: vaa.Address{2, 1, 4},
-			TargetAddress: padAddress(devnet.GanacheClientDefaultAccountAddress),
+			TargetAddress: ethereum.PadAddress(devnet.GanacheClientDefaultAccountAddress),
 			Asset: &vaa.AssetMeta{
 				Chain:    vaa.ChainIDSolana,
 				Address:  hexToAddress("0x347ef34687bdc9f189e87a9200658d9c40e9988"),
@@ -120,7 +121,7 @@ func main() {
 			SourceChain:   1,
 			TargetChain:   2,
 			SourceAddress: vaa.Address{2, 1, 5},
-			TargetAddress: padAddress(devnet.GanacheClientDefaultAccountAddress),
+			TargetAddress: ethereum.PadAddress(devnet.GanacheClientDefaultAccountAddress),
 			Asset: &vaa.AssetMeta{
 				Chain:    vaa.ChainIDSolana,
 				Address:  hexToAddress("0x347ef34687bdc9f189e87a9200658d9c40e9988"),
@@ -139,7 +140,7 @@ func main() {
 			SourceChain:   1,
 			TargetChain:   2,
 			SourceAddress: vaa.Address{2, 1, 5},
-			TargetAddress: padAddress(devnet.GanacheClientDefaultAccountAddress),
+			TargetAddress: ethereum.PadAddress(devnet.GanacheClientDefaultAccountAddress),
 			Asset: &vaa.AssetMeta{
 				Chain:    vaa.ChainIDSolana,
 				Address:  hexToAddress("0x347ef34687bdc9f189e87a9200658d9c40e9988"),
@@ -158,7 +159,7 @@ func main() {
 			SourceChain:   1,
 			TargetChain:   2,
 			SourceAddress: vaa.Address{2, 1, 5},
-			TargetAddress: padAddress(devnet.GanacheClientDefaultAccountAddress),
+			TargetAddress: ethereum.PadAddress(devnet.GanacheClientDefaultAccountAddress),
 			Asset: &vaa.AssetMeta{
 				Chain:    vaa.ChainIDSolana,
 				Address:  hexToAddress("0x347ef34687bdc9f189e87a9200658d9c40e9988"),
@@ -194,7 +195,7 @@ func main() {
 			SourceChain:   1,
 			TargetChain:   2,
 			SourceAddress: vaa.Address{2, 1, 5},
-			TargetAddress: padAddress(devnet.GanacheClientDefaultAccountAddress),
+			TargetAddress: ethereum.PadAddress(devnet.GanacheClientDefaultAccountAddress),
 			Asset: &vaa.AssetMeta{
 				Chain:    vaa.ChainIDSolana,
 				Address:  hexToAddress("0x347ef34687bdc9f189e87a9200658d9c40e9988"),
@@ -213,7 +214,7 @@ func main() {
 			SourceChain:   1,
 			TargetChain:   2,
 			SourceAddress: vaa.Address{2, 1, 5},
-			TargetAddress: padAddress(devnet.GanacheClientDefaultAccountAddress),
+			TargetAddress: ethereum.PadAddress(devnet.GanacheClientDefaultAccountAddress),
 			Asset: &vaa.AssetMeta{
 				Chain:    vaa.ChainIDSolana,
 				Address:  hexToAddress("0x347ef34687bdc9f189e87a9200658d9c40e9988"),
@@ -253,14 +254,5 @@ func generateKeys(n int) (keys []*ecdsa.PrivateKey) {
 
 func hexToAddress(hex string) vaa.Address {
 	hexAddr := common.HexToAddress(hex)
-	return padAddress(hexAddr)
-}
-
-func padAddress(address common.Address) vaa.Address {
-	paddedAddress := common.LeftPadBytes(address[:], 32)
-
-	addr := vaa.Address{}
-	copy(addr[:], paddedAddress)
-
-	return addr
+	return ethereum.PadAddress(hexAddr)
 }

+ 119 - 15
bridge/e2e/e2e_test.go

@@ -2,16 +2,20 @@ package e2e
 
 import (
 	"context"
-	"github.com/ethereum/go-ethereum/accounts/abi/bind"
-	"k8s.io/client-go/kubernetes"
 	"testing"
 	"time"
 
+	"github.com/ethereum/go-ethereum/accounts/abi/bind"
+	"github.com/mr-tron/base58"
+	"k8s.io/client-go/kubernetes"
+
 	"github.com/certusone/wormhole/bridge/pkg/devnet"
+	"github.com/certusone/wormhole/bridge/pkg/ethereum"
+	"github.com/certusone/wormhole/bridge/pkg/vaa"
 	"github.com/ethereum/go-ethereum/ethclient"
 )
 
-func setup(t *testing.T) (*kubernetes.Clientset, *ethclient.Client, *bind.TransactOpts) {
+func setup(t *testing.T) (*kubernetes.Clientset, *ethclient.Client, *bind.TransactOpts, *TerraClient) {
 	// List of pods we need in a ready state before we can run tests.
 	want := []string{
 		// Our test guardian set.
@@ -48,7 +52,13 @@ func setup(t *testing.T) (*kubernetes.Clientset, *ethclient.Client, *bind.Transa
 	}
 	kt := devnet.GetKeyedTransactor(context.Background())
 
-	return c, ec, kt
+	// Terra client
+	tc, err := NewTerraClient()
+	if err != nil {
+		t.Fatalf("creating devnet terra client failed: %v", err)
+	}
+
+	return c, ec, kt, tc
 }
 
 // Careful about parallel tests - accounts on some chains like Ethereum cannot be
@@ -56,7 +66,7 @@ func setup(t *testing.T) (*kubernetes.Clientset, *ethclient.Client, *bind.Transa
 // Either use different Ethereum account, or do not run Ethereum tests in parallel.
 
 func TestEndToEnd_SOL_ETH(t *testing.T) {
-	c, ec, kt := setup(t)
+	c, ec, kt, _ := setup(t)
 
 	t.Run("[SOL] Native -> [ETH] Wrapped", func(t *testing.T) {
 		testSolanaLockup(t, context.Background(), ec, c,
@@ -117,12 +127,7 @@ func TestEndToEnd_SOL_ETH(t *testing.T) {
 }
 
 func TestEndToEnd_SOL_Terra(t *testing.T) {
-	c, _, _ := setup(t)
-
-	tc, err := NewTerraClient()
-	if err != nil {
-		t.Fatalf("creating devnet terra client failed: %v", err)
-	}
+	c, _, _, tc := setup(t)
 
 	t.Run("[Terra] Native -> [SOL] Wrapped", func(t *testing.T) {
 		testTerraLockup(t, context.Background(), tc, c,
@@ -137,14 +142,29 @@ func TestEndToEnd_SOL_Terra(t *testing.T) {
 		)
 	})
 
-	// TODO(https://github.com/certusone/wormhole/issues/164): [SOL] Wrapped -> [Terra] Native
+	t.Run("[SOL] Wrapped -> [Terra] Native", func(t *testing.T) {
+		testSolanaToTerraLockup(t, context.Background(), c,
+			// Source SPL account
+			devnet.SolanaExampleWrappedCWTokenOwningAccount,
+			// Source SPL token
+			devnet.SolanaExampleWrappedCWToken,
+			// Wrapped
+			false,
+			// Amount of SPL token value to transfer.
+			2*devnet.TerraDefaultPrecision,
+			// Same precision - same amount, no precision gained.
+			0,
+		)
+	})
 
 	t.Run("[SOL] Native -> [Terra] Wrapped", func(t *testing.T) {
-		testSolanaToTerraLockup(t, context.Background(), tc, c,
+		testSolanaToTerraLockup(t, context.Background(), c,
 			// Source SPL account
 			devnet.SolanaExampleTokenOwningAccount,
 			// Source SPL token
 			devnet.SolanaExampleToken,
+			// Native
+			true,
 			// Amount of SPL token value to transfer.
 			50*devnet.SolanaDefaultPrecision,
 			// Same precision - same amount, no precision gained.
@@ -152,7 +172,91 @@ func TestEndToEnd_SOL_Terra(t *testing.T) {
 		)
 	})
 
-	// TODO(https://github.com/certusone/wormhole/issues/164): [Terra] Wrapped -> [SOL] Native
+	t.Run("[Terra] Wrapped -> [SOL] Native", func(t *testing.T) {
+
+		tokenSlice, err := base58.Decode(devnet.SolanaExampleToken)
+		if err != nil {
+			t.Fatal(err)
+		}
+		wrappedAsset, err := waitTerraAsset(t, context.Background(), devnet.TerraBridgeAddress, vaa.ChainIDSolana, tokenSlice)
+
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		testTerraLockup(t, context.Background(), tc, c,
+			// Source wrapped token
+			wrappedAsset,
+			// Destination SPL token account
+			devnet.SolanaExampleTokenOwningAccount,
+			// Amount of Terra token value to transfer.
+			50*devnet.SolanaDefaultPrecision,
+			// Same precision
+			0,
+		)
+	})
 }
 
-// TODO(https://github.com/certusone/wormhole/issues/164): TestEndToEnd_ETH_Terra
+func TestEndToEnd_ETH_Terra(t *testing.T) {
+	_, ec, kt, tc := setup(t)
+
+	t.Run("[Terra] Native -> [ETH] Wrapped", func(t *testing.T) {
+		testTerraToEthLockup(t, context.Background(), tc, ec,
+			// Source CW20 token
+			devnet.TerraTokenAddress,
+			// Destination ETH token
+			devnet.GanacheExampleERC20WrappedTerra,
+			// Amount
+			2*devnet.TerraDefaultPrecision,
+			// Same precision - same amount, no precision gained.
+			0,
+		)
+	})
+
+	t.Run("[ETH] Wrapped -> [Terra] Native", func(t *testing.T) {
+		testEthereumToTerraLockup(t, context.Background(), ec, kt,
+			// Source Ethereum token
+			devnet.GanacheExampleERC20WrappedTerra,
+			// Wrapped
+			false,
+			// Amount of Ethereum token value to transfer.
+			2*devnet.TerraDefaultPrecision,
+			// Same precision
+			0,
+		)
+	})
+
+	t.Run("[ETH] Native -> [Terra] Wrapped", func(t *testing.T) {
+		testEthereumToTerraLockup(t, context.Background(), ec, kt,
+			// Source Ethereum token
+			devnet.GanacheExampleERC20Token,
+			// Native
+			true,
+			// Amount of Ethereum token value to transfer.
+			0.000000012*devnet.ERC20DefaultPrecision,
+			// We lose 9 digits of precision on this path, as the default ERC20 token has 10**18 precision.
+			9,
+		)
+	})
+
+	t.Run("[Terra] Wrapped -> [ETH] Native", func(t *testing.T) {
+
+		paddedTokenAddress := ethereum.PadAddress(devnet.GanacheExampleERC20Token)
+		wrappedAsset, err := waitTerraAsset(t, context.Background(), devnet.TerraBridgeAddress, vaa.ChainIDEthereum, paddedTokenAddress[:])
+
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		testTerraToEthLockup(t, context.Background(), tc, ec,
+			// Source wrapped token
+			wrappedAsset,
+			// Destination ETH token
+			devnet.GanacheExampleERC20Token,
+			// Amount of Terra token value to transfer.
+			0.000000012*1e9, // 10**9 because default ETH precision is 18 and we lost 9 digits on wrapping
+			// We gain 9 digits of precision on Eth.
+			9,
+		)
+	})
+}

+ 83 - 0
bridge/e2e/eth.go

@@ -2,6 +2,7 @@ package e2e
 
 import (
 	"context"
+	"encoding/hex"
 	"math"
 	"math/big"
 	"testing"
@@ -15,6 +16,7 @@ import (
 	"k8s.io/client-go/kubernetes"
 
 	"github.com/certusone/wormhole/bridge/pkg/devnet"
+	"github.com/certusone/wormhole/bridge/pkg/ethereum"
 	"github.com/certusone/wormhole/bridge/pkg/ethereum/abi"
 	"github.com/certusone/wormhole/bridge/pkg/ethereum/erc20"
 	"github.com/certusone/wormhole/bridge/pkg/vaa"
@@ -105,3 +107,84 @@ func testEthereumLockup(t *testing.T, ctx context.Context, ec *ethclient.Client,
 	// Source account decreases by the full amount.
 	waitEthBalance(t, ctx, token, beforeErc20, -int64(amount))
 }
+
+func testEthereumToTerraLockup(t *testing.T, ctx context.Context, ec *ethclient.Client, kt *bind.TransactOpts,
+	tokenAddr common.Address, isNative bool, amount int64, precisionLoss int) {
+
+	// Bridge client
+	ethBridge, err := abi.NewAbi(devnet.GanacheBridgeContractAddress, ec)
+	if err != nil {
+		panic(err)
+	}
+
+	// Source token client
+	token, err := erc20.NewErc20(tokenAddr, ec)
+	if err != nil {
+		panic(err)
+	}
+
+	// Store balance of source ERC20 token
+	beforeErc20, err := token.BalanceOf(nil, devnet.GanacheClientDefaultAccountAddress)
+	if err != nil {
+		beforeErc20 = new(big.Int)
+		t.Log(err) // account may not yet exist, defaults to 0
+	}
+	t.Logf("ERC20 balance: %v", beforeErc20)
+
+	// Store balance of destination CW20 token
+	paddedTokenAddress := ethereum.PadAddress(tokenAddr)
+	var terraToken string
+	if isNative {
+		terraToken, err = getAssetAddress(ctx, devnet.TerraBridgeAddress, vaa.ChainIDEthereum, paddedTokenAddress[:])
+		if err != nil {
+			t.Log(err)
+		}
+	} else {
+		terraToken = devnet.TerraTokenAddress
+	}
+
+	// Get balance if deployed
+	beforeCw20, err := getTerraBalance(ctx, terraToken)
+	if err != nil {
+		beforeCw20 = new(big.Int)
+		t.Log(err) // account may not yet exist, defaults to 0
+	}
+	t.Logf("CW20 balance: %v", beforeCw20)
+
+	// Send lockup
+	dstAddress, err := hex.DecodeString(devnet.TerraMainTestAddressHex)
+	if err != nil {
+		t.Fatal(err)
+	}
+	var dstAddressBytes [32]byte
+	copy(dstAddressBytes[:], dstAddress)
+	tx, err := ethBridge.LockAssets(kt,
+		// asset address
+		tokenAddr,
+		// token amount
+		new(big.Int).SetInt64(amount),
+		// recipient address on target chain
+		dstAddressBytes,
+		// target chain
+		vaa.ChainIDTerra,
+		// random nonce
+		rand.Uint32(),
+		// refund dust?
+		false,
+	)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Logf("sent lockup tx: %v", tx.Hash().Hex())
+
+	// Destination account increases by the full amount.
+	if isNative {
+		waitTerraUnknownBalance(t, ctx, devnet.TerraBridgeAddress, vaa.ChainIDEthereum, paddedTokenAddress[:], beforeCw20, int64(float64(amount)/math.Pow10(precisionLoss)))
+	} else {
+		waitTerraBalance(t, ctx, devnet.TerraTokenAddress, beforeCw20, int64(float64(amount)/math.Pow10(precisionLoss)))
+	}
+
+	// Source account decreases by the full amount.
+	waitEthBalance(t, ctx, token, beforeErc20, -int64(amount))
+}

+ 16 - 4
bridge/e2e/solana.go

@@ -119,14 +119,22 @@ func testSolanaLockup(t *testing.T, ctx context.Context, ec *ethclient.Client, c
 	waitSPLBalance(t, ctx, c, sourceAcct, beforeSPL, -int64(amount))
 }
 
-func testSolanaToTerraLockup(t *testing.T, ctx context.Context, tc *TerraClient, c *kubernetes.Clientset,
-	sourceAcct string, tokenAddr string, amount int, precisionGain int) {
+func testSolanaToTerraLockup(t *testing.T, ctx context.Context, c *kubernetes.Clientset,
+	sourceAcct string, tokenAddr string, isNative bool, amount int, precisionGain int) {
 
 	tokenSlice, err := base58.Decode(tokenAddr)
 	if err != nil {
 		t.Fatal(err)
 	}
-	terraToken, err := getAssetAddress(ctx, devnet.TerraBridgeAddress, vaa.ChainIDSolana, tokenSlice)
+	var terraToken string
+	if isNative {
+		terraToken, err = getAssetAddress(ctx, devnet.TerraBridgeAddress, vaa.ChainIDSolana, tokenSlice)
+		if err != nil {
+			t.Log(err)
+		}
+	} else {
+		terraToken = devnet.TerraTokenAddress
+	}
 
 	// Get balance if deployed
 	beforeCw20, err := getTerraBalance(ctx, terraToken)
@@ -168,5 +176,9 @@ func testSolanaToTerraLockup(t *testing.T, ctx context.Context, tc *TerraClient,
 	waitSPLBalance(t, ctx, c, sourceAcct, beforeSPL, -int64(amount))
 
 	// Destination account increases by the full amount.
-	waitTerraUnknownBalance(t, ctx, devnet.TerraBridgeAddress, vaa.ChainIDSolana, tokenSlice, beforeCw20, int64(float64(amount)*math.Pow10(precisionGain)))
+	if isNative {
+		waitTerraUnknownBalance(t, ctx, devnet.TerraBridgeAddress, vaa.ChainIDSolana, tokenSlice, beforeCw20, int64(float64(amount)*math.Pow10(precisionGain)))
+	} else {
+		waitTerraBalance(t, ctx, devnet.TerraTokenAddress, beforeCw20, int64(float64(amount)*math.Pow10(precisionGain)))
+	}
 }

+ 69 - 7
bridge/e2e/terra.go

@@ -9,11 +9,16 @@ import (
 	"math"
 	"math/big"
 	"net/http"
+	"net/url"
 	"testing"
 	"time"
 
 	"github.com/certusone/wormhole/bridge/pkg/devnet"
+	"github.com/certusone/wormhole/bridge/pkg/ethereum"
+	"github.com/certusone/wormhole/bridge/pkg/ethereum/erc20"
 	"github.com/certusone/wormhole/bridge/pkg/vaa"
+	"github.com/ethereum/go-ethereum/common"
+	"github.com/ethereum/go-ethereum/ethclient"
 	"github.com/tendermint/tendermint/libs/rand"
 	"github.com/terra-project/terra.go/client"
 	"github.com/terra-project/terra.go/key"
@@ -168,7 +173,7 @@ func getAssetAddress(ctx context.Context, contract string, chain uint8, asset []
 
 func terraQuery(ctx context.Context, contract string, query string) (string, error) {
 
-	requestURL := fmt.Sprintf("%s/wasm/contracts/%s/store?query_msg=%s", devnet.TerraLCDURL, contract, query)
+	requestURL := fmt.Sprintf("%s/wasm/contracts/%s/store?query_msg=%s", devnet.TerraLCDURL, contract, url.QueryEscape(query))
 
 	req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
 	if err != nil {
@@ -193,21 +198,27 @@ func terraQuery(ctx context.Context, contract string, query string) (string, err
 
 // waitTerraAsset waits for asset contract to be deployed on terra
 func waitTerraAsset(t *testing.T, ctx context.Context, contract string, chain uint8, asset []byte) (string, error) {
-	ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
+	ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
 	defer cancel()
 
 	assetAddress := ""
 
-	err := wait.PollUntil(1*time.Second, func() (bool, error) {
+	err := wait.PollUntil(3*time.Second, func() (bool, error) {
 
 		address, err := getAssetAddress(ctx, contract, chain, asset)
 		if err != nil {
 			t.Log(err)
-			return true, nil
+			return false, nil
 		}
 
+		// Check the case if request was successful, but asset address is not yet in the registry
+		if address == "" {
+			return false, nil
+		}
+		t.Logf("Returning asset: %s", address)
+
 		assetAddress = address
-		return false, nil
+		return true, nil
 	}, ctx.Done())
 
 	if err != nil {
@@ -252,10 +263,10 @@ func waitTerraUnknownBalance(t *testing.T, ctx context.Context, contract string,
 		return
 	}
 
-	ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
+	ctx, cancel := context.WithTimeout(ctx, 90*time.Second)
 	defer cancel()
 
-	err = wait.PollUntil(1*time.Second, func() (bool, error) {
+	err = wait.PollUntil(3*time.Second, func() (bool, error) {
 
 		after, err := getTerraBalance(ctx, token)
 		if err != nil {
@@ -322,3 +333,54 @@ func testTerraLockup(t *testing.T, ctx context.Context, tc *TerraClient,
 	// Source account decreases by the full amount.
 	waitTerraBalance(t, ctx, token, beforeCw20, -int64(amount))
 }
+
+func testTerraToEthLockup(t *testing.T, ctx context.Context, tc *TerraClient,
+	ec *ethclient.Client, tokenAddr string, destination common.Address, amount int64, precisionGain int) {
+
+	token, err := erc20.NewErc20(destination, ec)
+	if err != nil {
+		panic(err)
+	}
+
+	// Store balance of source CW20 token
+	beforeCw20, err := getTerraBalance(ctx, tokenAddr)
+	if err != nil {
+		t.Log(err) // account may not yet exist, defaults to 0
+		beforeCw20 = new(big.Int)
+	}
+	t.Logf("CW20 balance: %v", beforeCw20)
+
+	/// Store balance of wrapped destination token
+	beforeErc20, err := token.BalanceOf(nil, devnet.GanacheClientDefaultAccountAddress)
+	if err != nil {
+		t.Log(err) // account may not yet exist, defaults to 0
+		beforeErc20 = new(big.Int)
+	}
+	t.Logf("ERC20 balance: %v", beforeErc20)
+
+	// Send lockup
+	tx, err := tc.lockAssets(
+		t, ctx,
+		// asset address
+		tokenAddr,
+		// token amount
+		new(big.Int).SetInt64(amount),
+		// recipient address on target chain
+		ethereum.PadAddress(devnet.GanacheClientDefaultAccountAddress),
+		// target chain
+		vaa.ChainIDEthereum,
+		// random nonce
+		rand.Uint32(),
+	)
+	if err != nil {
+		t.Error(err)
+	}
+
+	t.Logf("sent lockup tx: %s", tx.TxHash)
+
+	// Destination account increases by full amount.
+	waitEthBalance(t, ctx, token, beforeErc20, int64(float64(amount)*math.Pow10(precisionGain)))
+
+	// Source account decreases by the full amount.
+	waitTerraBalance(t, ctx, tokenAddr, beforeCw20, -int64(amount))
+}

+ 3 - 2
bridge/pkg/devnet/constants.go

@@ -26,8 +26,9 @@ var (
 	GanacheBridgeContractAddress = common.HexToAddress("0x5b1869D9A4C187F2EAa108f3062412ecf0526b24")
 
 	// ERC20 example tokens.
-	GanacheExampleERC20Token      = common.HexToAddress("0xCfEB869F69431e42cdB54A4F4f105C19C080A601")
-	GanacheExampleERC20WrappedSOL = common.HexToAddress("0xf5b1d8fab1054b9cf7db274126972f97f9d42a11")
+	GanacheExampleERC20Token        = common.HexToAddress("0xCfEB869F69431e42cdB54A4F4f105C19C080A601")
+	GanacheExampleERC20WrappedSOL   = common.HexToAddress("0xf5b1d8fab1054b9cf7db274126972f97f9d42a11")
+	GanacheExampleERC20WrappedTerra = common.HexToAddress("0x62b47a23cd900da982bdbe75aeb891d3ed18cc36")
 )
 
 const (

+ 16 - 0
bridge/pkg/ethereum/utils.go

@@ -0,0 +1,16 @@
+package ethereum
+
+import (
+	"github.com/certusone/wormhole/bridge/pkg/vaa"
+	"github.com/ethereum/go-ethereum/common"
+)
+
+// PadAddress creates 32-byte VAA.Address from 20-byte Ethereum addresses by adding 12 0-bytes at the left
+func PadAddress(address common.Address) vaa.Address {
+	paddedAddress := common.LeftPadBytes(address[:], 32)
+
+	addr := vaa.Address{}
+	copy(addr[:], paddedAddress)
+
+	return addr
+}

+ 2 - 1
bridge/pkg/ethereum/watcher.go

@@ -3,11 +3,12 @@ package ethereum
 import (
 	"context"
 	"fmt"
-	"github.com/prometheus/client_golang/prometheus"
 	"math/big"
 	"sync"
 	"time"
 
+	"github.com/prometheus/client_golang/prometheus"
+
 	"github.com/ethereum/go-ethereum/accounts/abi/bind"
 	eth_common "github.com/ethereum/go-ethereum/common"
 	"github.com/ethereum/go-ethereum/core/types"

+ 2 - 1
terra/contracts/wormhole/src/contract.rs

@@ -353,6 +353,7 @@ fn vaa_transfer<S: Storage, A: Api, Q: Querier>(
     const TARGET_ADDRESS_POS: usize = 38;
     const TOKEN_CHAIN_POS: usize = 70;
     const TOKEN_ADDRESS_POS: usize = 71;
+    const DECIMALS_POS: usize = 103;
     const AMOUNT_POS: usize = 104;
     const PAYLOAD_LEN: usize = 136;
 
@@ -413,7 +414,7 @@ fn vaa_transfer<S: Storage, A: Api, Q: Querier>(
                     msg: to_binary(&WrappedInit {
                         asset_chain: token_chain,
                         asset_address: asset_address.to_vec().into(),
-                        decimals: data.get_u8(103),
+                        decimals: data.get_u8(DECIMALS_POS),
                         mint: Some(InitMint {
                             recipient: deps
                                 .api