From be814e88129562adaa0922632b6fa07f6c000cc2 Mon Sep 17 00:00:00 2001 From: Steve <1848680+misko9@users.noreply.github.com> Date: Thu, 15 Aug 2024 16:49:33 -0600 Subject: [PATCH] feat(utxo): Add support for utxo chains (#1220) * Add support for utxo chains * remove copy paste comment * remove unnecessary config items * Check for keyname entry in keystore map --- chain/cosmos/chain_node.go | 2 +- chain/cosmos/cosmos_chain.go | 5 + chain/cosmos/module_bank.go | 6 + chain/cosmos/sidecar.go | 2 +- chain/ethereum/ethererum_chain.go | 56 +- chain/internal/tendermint/tendermint_node.go | 2 +- chain/penumbra/penumbra_app_node.go | 2 +- chain/penumbra/penumbra_chain.go | 7 + chain/penumbra/penumbra_client_node.go | 2 +- chain/polkadot/parachain_node.go | 2 +- chain/polkadot/polkadot_chain.go | 6 + chain/polkadot/relay_chain_node.go | 2 +- chain/utxo/cli.go | 403 ++++++++++++++ chain/utxo/default_configs.go | 141 +++++ chain/utxo/types.go | 62 +++ chain/utxo/unimplemented.go | 63 +++ chain/utxo/utxo_chain.go | 520 +++++++++++++++++++ chain/utxo/wallet.go | 107 ++++ chainfactory.go | 3 + chainspec.go | 10 +- dockerutil/container_lifecycle.go | 3 +- examples/ethereum/start_test.go | 3 +- examples/utxo/start_test.go | 132 +++++ ibc/chain.go | 3 + relayer/docker.go | 2 +- 25 files changed, 1527 insertions(+), 19 deletions(-) create mode 100644 chain/utxo/cli.go create mode 100644 chain/utxo/default_configs.go create mode 100644 chain/utxo/types.go create mode 100644 chain/utxo/unimplemented.go create mode 100644 chain/utxo/utxo_chain.go create mode 100644 chain/utxo/wallet.go create mode 100644 examples/utxo/start_test.go diff --git a/chain/cosmos/chain_node.go b/chain/cosmos/chain_node.go index d426417e0..994bc6894 100644 --- a/chain/cosmos/chain_node.go +++ b/chain/cosmos/chain_node.go @@ -1169,7 +1169,7 @@ func (tn *ChainNode) CreateNodeContainer(ctx context.Context) error { fmt.Printf("Port Overrides: %v. Using: %v\n", chainCfg.HostPortOverride, usingPorts) } - return tn.containerLifecycle.CreateContainer(ctx, tn.TestName, tn.NetworkID, tn.Image, usingPorts, tn.Bind(), nil, tn.HostName(), cmd, chainCfg.Env) + return tn.containerLifecycle.CreateContainer(ctx, tn.TestName, tn.NetworkID, tn.Image, usingPorts, tn.Bind(), nil, tn.HostName(), cmd, chainCfg.Env, []string{}) } func (tn *ChainNode) StartContainer(ctx context.Context) error { diff --git a/chain/cosmos/cosmos_chain.go b/chain/cosmos/cosmos_chain.go index 75287c3f6..6210c31ae 100644 --- a/chain/cosmos/cosmos_chain.go +++ b/chain/cosmos/cosmos_chain.go @@ -345,6 +345,11 @@ func (c *CosmosChain) SendFunds(ctx context.Context, keyName string, amount ibc. return c.getFullNode().BankSend(ctx, keyName, amount) } +// Implements Chain interface +func (c *CosmosChain) SendFundsWithNote(ctx context.Context, keyName string, amount ibc.WalletAmount, note string) (string, error) { + return c.getFullNode().BankSendWithNote(ctx, keyName, amount, note) +} + // Implements Chain interface func (c *CosmosChain) SendIBCTransfer( ctx context.Context, diff --git a/chain/cosmos/module_bank.go b/chain/cosmos/module_bank.go index bbd983bf1..5f54071ce 100644 --- a/chain/cosmos/module_bank.go +++ b/chain/cosmos/module_bank.go @@ -21,6 +21,12 @@ func (tn *ChainNode) BankSend(ctx context.Context, keyName string, amount ibc.Wa return err } +// BankSend sends tokens from one account to another. +func (tn *ChainNode) BankSendWithNote(ctx context.Context, keyName string, amount ibc.WalletAmount, note string) (string, error) { + return tn.ExecTx(ctx, keyName, "bank", "send", keyName, amount.Address, + fmt.Sprintf("%s%s", amount.Amount.String(), amount.Denom), "--note", note) +} + // Deprecated: use BankSend instead func (tn *ChainNode) SendFunds(ctx context.Context, keyName string, amount ibc.WalletAmount) error { return tn.BankSend(ctx, keyName, amount) diff --git a/chain/cosmos/sidecar.go b/chain/cosmos/sidecar.go index 03684635e..9927e6ec0 100644 --- a/chain/cosmos/sidecar.go +++ b/chain/cosmos/sidecar.go @@ -107,7 +107,7 @@ func (s *SidecarProcess) logger() *zap.Logger { } func (s *SidecarProcess) CreateContainer(ctx context.Context) error { - return s.containerLifecycle.CreateContainer(ctx, s.TestName, s.NetworkID, s.Image, s.ports, s.Bind(), nil, s.HostName(), s.startCmd, s.env) + return s.containerLifecycle.CreateContainer(ctx, s.TestName, s.NetworkID, s.Image, s.ports, s.Bind(), nil, s.HostName(), s.startCmd, s.env, []string{}) } func (s *SidecarProcess) StartContainer(ctx context.Context) error { diff --git a/chain/ethereum/ethererum_chain.go b/chain/ethereum/ethererum_chain.go index 172878469..dbf5959f6 100644 --- a/chain/ethereum/ethererum_chain.go +++ b/chain/ethereum/ethererum_chain.go @@ -17,6 +17,7 @@ import ( "github.com/docker/docker/api/types/volume" dockerclient "github.com/docker/docker/client" "github.com/docker/go-connections/nat" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/strangelove-ventures/interchaintest/v8/dockerutil" "github.com/strangelove-ventures/interchaintest/v8/ibc" "github.com/strangelove-ventures/interchaintest/v8/testutil" @@ -222,7 +223,7 @@ func (c *EthereumChain) Start(testName string, ctx context.Context, additionalGe fmt.Printf("Port Overrides: %v. Using: %v\n", c.cfg.HostPortOverride, usingPorts) } - err := c.containerLifecycle.CreateContainer(ctx, c.testName, c.NetworkID, c.cfg.Images[0], usingPorts, c.Bind(), mounts, c.HostName(), cmd, nil) + err := c.containerLifecycle.CreateContainer(ctx, c.testName, c.NetworkID, c.cfg.Images[0], usingPorts, c.Bind(), mounts, c.HostName(), cmd, nil, []string{}) if err != nil { return err } @@ -298,6 +299,11 @@ func (c *EthereumChain) CreateKey(ctx context.Context, keyName string) error { return err } + _, ok := c.keystoreMap[keyName] + if ok { + return fmt.Errorf("Keyname (%s) already used", keyName) + } + cmd := []string{"cast", "wallet", "new", c.KeystoreDir(), "--unsafe-password", "", "--json"} stdout, _, err := c.Exec(ctx, cmd, nil) if err != nil { @@ -317,8 +323,12 @@ func (c *EthereumChain) CreateKey(ctx context.Context, keyName string) error { // Get address of account, cast to a string to use func (c *EthereumChain) GetAddress(ctx context.Context, keyName string) ([]byte, error) { + keystore, ok := c.keystoreMap[keyName] + if !ok { + return nil, fmt.Errorf("Keyname (%s) not found", keyName) + } - cmd := []string{"cast", "wallet", "address", "--keystore", c.keystoreMap[keyName], "--password", ""} + cmd := []string{"cast", "wallet", "address", "--keystore", keystore, "--password", ""} stdout, _, err := c.Exec(ctx, cmd, nil) if err != nil { return nil, err @@ -335,8 +345,12 @@ func (c *EthereumChain) SendFunds(ctx context.Context, keyName string, amount ib ) } else { + keystore, ok := c.keystoreMap[keyName] + if !ok { + return fmt.Errorf("keyname (%s) not found", keyName) + } cmd = append(cmd, - "--keystore", c.keystoreMap[keyName], + "--keystore", keystore, "--password", "", "--rpc-url", c.GetRPCAddress(), ) @@ -345,6 +359,42 @@ func (c *EthereumChain) SendFunds(ctx context.Context, keyName string, amount ib return err } +type TransactionReceipt struct { + TxHash string `json:"transactionHash"` +} + +func (c *EthereumChain) SendFundsWithNote(ctx context.Context, keyName string, amount ibc.WalletAmount, note string) (string, error) { + cmd := []string{"cast", "send", amount.Address, hexutil.Encode([]byte(note)), "--value", amount.Amount.String(), "--json"} + if keyName == "faucet" { + cmd = append(cmd, + "--private-key", "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", c.GetRPCAddress(), + ) + + } else { + keystore, ok := c.keystoreMap[keyName] + if !ok { + return "", fmt.Errorf("Keyname (%s) not found", keyName) + } + cmd = append(cmd, + "--keystore", keystore, + "--password", "", + "--rpc-url", c.GetRPCAddress(), + ) + } + stdout, _, err := c.Exec(ctx, cmd, nil) + if err != nil { + return "", err + } + + var txReceipt TransactionReceipt + if err = json.Unmarshal(stdout, &txReceipt); err != nil { + return "", err + } + + return txReceipt.TxHash, nil +} + func (c *EthereumChain) Height(ctx context.Context) (int64, error) { cmd := []string{"cast", "block-number", "--rpc-url", c.GetRPCAddress()} stdout, _, err := c.Exec(ctx, cmd, nil) diff --git a/chain/internal/tendermint/tendermint_node.go b/chain/internal/tendermint/tendermint_node.go index 27f2226a4..ab9ff578f 100644 --- a/chain/internal/tendermint/tendermint_node.go +++ b/chain/internal/tendermint/tendermint_node.go @@ -272,7 +272,7 @@ func (tn *TendermintNode) CreateNodeContainer(ctx context.Context, additionalFla cmd := []string{chainCfg.Bin, "start", "--home", tn.HomeDir()} cmd = append(cmd, additionalFlags...) - return tn.containerLifecycle.CreateContainer(ctx, tn.TestName, tn.NetworkID, tn.Image, sentryPorts, tn.Bind(), nil, tn.HostName(), cmd, nil) + return tn.containerLifecycle.CreateContainer(ctx, tn.TestName, tn.NetworkID, tn.Image, sentryPorts, tn.Bind(), nil, tn.HostName(), cmd, nil, []string{}) } func (tn *TendermintNode) StopContainer(ctx context.Context) error { diff --git a/chain/penumbra/penumbra_app_node.go b/chain/penumbra/penumbra_app_node.go index a3ba64901..7f61ed81d 100644 --- a/chain/penumbra/penumbra_app_node.go +++ b/chain/penumbra/penumbra_app_node.go @@ -352,7 +352,7 @@ func (p *PenumbraAppNode) CreateNodeContainer(ctx context.Context, tendermintAdd "--home", p.HomeDir(), } - return p.containerLifecycle.CreateContainer(ctx, p.TestName, p.NetworkID, p.Image, exposedPorts, p.Bind(), nil, p.HostName(), cmd, p.Chain.Config().Env) + return p.containerLifecycle.CreateContainer(ctx, p.TestName, p.NetworkID, p.Image, exposedPorts, p.Bind(), nil, p.HostName(), cmd, p.Chain.Config().Env, []string{}) } // StopContainer stops the running container for the PenumbraAppNode. diff --git a/chain/penumbra/penumbra_chain.go b/chain/penumbra/penumbra_chain.go index 50ad37f91..c2e64d87f 100644 --- a/chain/penumbra/penumbra_chain.go +++ b/chain/penumbra/penumbra_chain.go @@ -234,6 +234,13 @@ func (c *PenumbraChain) SendFunds(ctx context.Context, keyName string, amount ib return fn.PenumbraClientNodes[keyName].SendFunds(ctx, amount) } + +// SendFundsWithNote will initiate a local transfer from the account associated with the specified keyName, +// amount, token denom, and recipient are specified in the amount and attach a note/memo +func (c *PenumbraChain) SendFundsWithNote(ctx context.Context, keyName string, amount ibc.WalletAmount, note string) (string, error) { + panic("Penumbrachain: SendFundsWithNote unimplemented") +} + // SendIBCTransfer attempts to send a fungible token transfer via IBC from the specified account on the source chain // to the specified account on the counterparty chain. func (c *PenumbraChain) SendIBCTransfer( diff --git a/chain/penumbra/penumbra_client_node.go b/chain/penumbra/penumbra_client_node.go index 1b83c14bb..848912a67 100644 --- a/chain/penumbra/penumbra_client_node.go +++ b/chain/penumbra/penumbra_client_node.go @@ -561,7 +561,7 @@ func (p *PenumbraClientNode) CreateNodeContainer(ctx context.Context) error { "start", } - return p.containerLifecycle.CreateContainer(ctx, p.TestName, p.NetworkID, p.Image, pclientdPorts, p.Bind(), nil, p.HostName(), cmd, p.Chain.Config().Env) + return p.containerLifecycle.CreateContainer(ctx, p.TestName, p.NetworkID, p.Image, pclientdPorts, p.Bind(), nil, p.HostName(), cmd, p.Chain.Config().Env, []string{}) } // StopContainer stops the container associated with the PenumbraClientNode. diff --git a/chain/polkadot/parachain_node.go b/chain/polkadot/parachain_node.go index b9238596d..43bdbebea 100644 --- a/chain/polkadot/parachain_node.go +++ b/chain/polkadot/parachain_node.go @@ -256,7 +256,7 @@ func (pn *ParachainNode) CreateNodeContainer(ctx context.Context) error { cmd = append(cmd, "--", fmt.Sprintf("--chain=%s", pn.RawRelayChainSpecFilePathFull())) cmd = append(cmd, pn.RelayChainFlags...) - return pn.containerLifecycle.CreateContainer(ctx, pn.TestName, pn.NetworkID, pn.Image, exposedPorts, pn.Bind(), nil, pn.HostName(), cmd, nil) + return pn.containerLifecycle.CreateContainer(ctx, pn.TestName, pn.NetworkID, pn.Image, exposedPorts, pn.Bind(), nil, pn.HostName(), cmd, nil, []string{}) } // StopContainer stops the relay chain node container, waiting at most 30 seconds. diff --git a/chain/polkadot/polkadot_chain.go b/chain/polkadot/polkadot_chain.go index 96506150d..6c71c3b39 100644 --- a/chain/polkadot/polkadot_chain.go +++ b/chain/polkadot/polkadot_chain.go @@ -770,6 +770,12 @@ func (c *PolkadotChain) SendFunds(ctx context.Context, keyName string, amount ib return c.ParachainNodes[0][0].SendFunds(ctx, keyName, amount) } +// SendFundsWithNote sends funds to a wallet from a user account with a note/memo. +// Implements Chain interface. +func (c *PolkadotChain) SendFundsWithNote(ctx context.Context, keyName string, amount ibc.WalletAmount, note string) (string, error) { + panic("PolkadotChain: SendFundsWithNote unimplemented") +} + // SendIBCTransfer sends an IBC transfer returning a transaction or an error if the transfer failed. // Implements Chain interface. func (c *PolkadotChain) SendIBCTransfer( diff --git a/chain/polkadot/relay_chain_node.go b/chain/polkadot/relay_chain_node.go index 328b9f532..27a440938 100644 --- a/chain/polkadot/relay_chain_node.go +++ b/chain/polkadot/relay_chain_node.go @@ -224,7 +224,7 @@ func (p *RelayChainNode) CreateNodeContainer(ctx context.Context) error { fmt.Sprintf("--public-addr=%s", multiAddress), "--base-path", p.NodeHome(), } - return p.containerLifecycle.CreateContainer(ctx, p.TestName, p.NetworkID, p.Image, exposedPorts, p.Bind(), nil, p.HostName(), cmd, nil) + return p.containerLifecycle.CreateContainer(ctx, p.TestName, p.NetworkID, p.Image, exposedPorts, p.Bind(), nil, p.HostName(), cmd, nil, []string{}) } // StopContainer stops the relay chain node container, waiting at most 30 seconds. diff --git a/chain/utxo/cli.go b/chain/utxo/cli.go new file mode 100644 index 000000000..d5b701a8d --- /dev/null +++ b/chain/utxo/cli.go @@ -0,0 +1,403 @@ +package utxo + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// Depending on the wallet version, getwalletinfo may require a created wallet name +func (c *UtxoChain) GetWalletVersion(ctx context.Context, keyName string) (int, error) { + var walletInfo WalletInfo + var stdout []byte + var err error + + if keyName == "" { + cmd := append(c.BaseCli, "getwalletinfo") + stdout, _, err = c.Exec(ctx, cmd, nil) + if err != nil { + return 0, err + } + } else { + if err := c.LoadWallet(ctx, keyName); err != nil { + return 0, err + } + + cmd := append(c.BaseCli, fmt.Sprintf("-rpcwallet=%s", keyName), "getwalletinfo") + stdout, _, err = c.Exec(ctx, cmd, nil) + if err != nil { + return 0, err + } + if err := c.UnloadWallet(ctx, keyName); err != nil { + return 0, err + } + } + + if err := json.Unmarshal(stdout, &walletInfo); err != nil { + return 0, err + } + + return walletInfo.WalletVersion, nil +} + +// UnloadWalletAfterUse() sets whether non-default wallets stay loaded +// Default value is false, wallets will stay loaded +// Setting this to true will load/unload a wallet for each action on a specific wallet. +// Currently, the only know case where this is required true is when using bifrost. +func (c *UtxoChain) UnloadWalletAfterUse(on bool) { + c.unloadWalletAfterUse = on +} + +func (c *UtxoChain) LoadWallet(ctx context.Context, keyName string) error { + if !c.unloadWalletAfterUse { + return nil + } + + if c.WalletVersion == 0 || c.WalletVersion >= noDefaultKeyWalletVersion { + wallet, err := c.getWallet(keyName) + if err != nil { + return err + } + wallet.mu.Lock() + defer wallet.mu.Unlock() + if wallet.loadCount == 0 { + cmd := append(c.BaseCli, "loadwallet", keyName) + _, _, err = c.Exec(ctx, cmd, nil) + if err != nil { + return err + } + } + wallet.loadCount++ + } + return nil +} + +func (c *UtxoChain) UnloadWallet(ctx context.Context, keyName string) error { + if !c.unloadWalletAfterUse { + return nil + } + + if c.WalletVersion == 0 || c.WalletVersion >= noDefaultKeyWalletVersion { + wallet, err := c.getWallet(keyName) + if err != nil { + return err + } + wallet.mu.Lock() + defer wallet.mu.Unlock() + if wallet.loadCount == 1 { + cmd := append(c.BaseCli, "unloadwallet", keyName) + _, _, err = c.Exec(ctx, cmd, nil) + if err != nil { + return err + } + } + if wallet.loadCount > 0 { + wallet.loadCount-- + } + } + return nil +} + +func (c *UtxoChain) CreateWallet(ctx context.Context, keyName string) error { + if c.WalletVersion == 0 || c.WalletVersion >= noDefaultKeyWalletVersion { + cmd := append(c.BaseCli, "createwallet", keyName) + _, _, err := c.Exec(ctx, cmd, nil) + if err != nil { + return err + } + + c.KeyNameToWalletMap[keyName] = &NodeWallet{ + keyName: keyName, + loadCount: 1, + } + } + + return c.UnloadWallet(ctx, keyName) +} + +func (c *UtxoChain) GetNewAddress(ctx context.Context, keyName string, mweb bool) (string, error) { + wallet, err := c.getWalletForNewAddress(keyName) + if err != nil { + return "", err + } + + if err := c.LoadWallet(ctx, keyName); err != nil { + return "", err + } + + var cmd []string + if c.WalletVersion >= noDefaultKeyWalletVersion { + cmd = append(c.BaseCli, fmt.Sprintf("-rpcwallet=%s", keyName), "getnewaddress") + } else { + cmd = append(c.BaseCli, "getnewaddress") + } + + if mweb { + cmd = append(cmd, "mweb", "mweb") + } + + stdout, _, err := c.Exec(ctx, cmd, nil) + if err != nil { + return "", err + } + addr := strings.TrimSpace(string(stdout)) + + // Remove "bchreg:" from addresses like: bchreg:qz2lxh4vzg2tqw294p7d6taxntu2snwnjuxd2k9auq + splitAddr := strings.Split(addr, ":") + if len(splitAddr) == 2 { + addr = splitAddr[1] + } + + wallet.address = addr + c.AddrToKeyNameMap[addr] = keyName + + if c.WalletVersion >= noDefaultKeyWalletVersion { + wallet.ready = true + } + + if err := c.UnloadWallet(ctx, keyName); err != nil { + return "", err + } + + return addr, nil +} + +func (c *UtxoChain) SetAccount(ctx context.Context, addr string, keyName string) error { + if c.WalletVersion < noDefaultKeyWalletVersion { + wallet, err := c.getWalletForSetAccount(keyName, addr) + if err != nil { + return err + } + cmd := append(c.BaseCli, "setaccount", addr, keyName) + _, _, err = c.Exec(ctx, cmd, nil) + if err != nil { + return err + } + wallet.ready = true + } + + return nil +} + +// sendToMwebAddress is used for creating the mweb transaction needed at block 431 +// no other use case is currently supported +func (c *UtxoChain) sendToMwebAddress(ctx context.Context, keyName string, addr string, amount float64) error { + _, err := c.getWalletForUse(keyName) + if err != nil { + return err + } + + if err := c.LoadWallet(ctx, keyName); err != nil { + return err + } + + cmd := append(c.BaseCli, + fmt.Sprintf("-rpcwallet=%s", keyName), "-named", "sendtoaddress", + fmt.Sprintf("address=%s", addr), + fmt.Sprintf("amount=%.8f", amount), + ) + + _, _, err = c.Exec(ctx, cmd, nil) + if err != nil { + return err + } + + return c.UnloadWallet(ctx, keyName) +} + +func (c *UtxoChain) ListUnspent(ctx context.Context, keyName string) (ListUtxo, error) { + wallet, err := c.getWalletForUse(keyName) + if err != nil { + return nil, err + } + + if err := c.LoadWallet(ctx, keyName); err != nil { + return nil, err + } + + var cmd []string + if c.WalletVersion >= noDefaultKeyWalletVersion { + cmd = append(c.BaseCli, fmt.Sprintf("-rpcwallet=%s", keyName), "listunspent") + } else { + cmd = append(c.BaseCli, "listunspent", "0", "99999999", fmt.Sprintf("[\"%s\"]", wallet.address)) + } + + stdout, _, err := c.Exec(ctx, cmd, nil) + if err != nil { + return nil, err + } + + if err := c.UnloadWallet(ctx, keyName); err != nil { + return nil, err + } + + var listUtxo ListUtxo + if err := json.Unmarshal(stdout, &listUtxo); err != nil { + return nil, err + } + + return listUtxo, nil +} + +func (c *UtxoChain) CreateRawTransaction(ctx context.Context, keyName string, listUtxo ListUtxo, addr string, sendAmount float64, script []byte) (string, error) { + wallet, err := c.getWalletForUse(keyName) + if err != nil { + return "", err + } + + var sendInputs SendInputs + utxoTotal := float64(0.0) + fees, err := strconv.ParseFloat(c.cfg.GasPrices, 64) + if err != nil { + return "", err + } + fees = fees * c.cfg.GasAdjustment + for _, utxo := range listUtxo { + if wallet.address == utxo.Address || strings.Contains(utxo.Address, wallet.address) { + sendInputs = append(sendInputs, SendInput{ + TxId: utxo.TxId, + Vout: utxo.Vout, + }) + utxoTotal += utxo.Amount + if utxoTotal > sendAmount+fees { + break + } + } + } + sendInputsBz, err := json.Marshal(sendInputs) + if err != nil { + return "", err + } + + sanitizedSendAmount, err := strconv.ParseFloat(fmt.Sprintf("%.8f", sendAmount), 64) + if err != nil { + return "", err + } + + sanitizedChange, err := strconv.ParseFloat(fmt.Sprintf("%.8f", utxoTotal-sendAmount-fees), 64) + if err != nil { + return "", err + } + + var sendOutputsBz []byte + if c.WalletVersion >= noDefaultKeyWalletVersion { + sendOutputs := SendOutputs{ + SendOutput{ + Amount: sanitizedSendAmount, + }, + SendOutput{ + Change: sanitizedChange, + }, + } + + if len(script) > 0 { + sendOutputs = append(sendOutputs, SendOutput{ + Data: hex.EncodeToString(script), + }) + } + + sendOutputsBz, err = json.Marshal(sendOutputs) + if err != nil { + return "", err + } + } else { + sendOutputs := SendOutput{ + Amount: sanitizedSendAmount, + Change: sanitizedChange, + Data: hex.EncodeToString(script), + } + + sendOutputsBz, err = json.Marshal(sendOutputs) + if err != nil { + return "", err + } + } + + // create raw transaction + + sendInputsStr := string(sendInputsBz) + sendOutputsStr := strings.Replace(string(sendOutputsBz), "replaceWithAddress", addr, 1) + sendOutputsStr = strings.Replace(sendOutputsStr, "replaceWithChangeAddr", wallet.address, 1) + + // createrawtransaction + cmd := append(c.BaseCli, "createrawtransaction", sendInputsStr, sendOutputsStr) + stdout, _, err := c.Exec(ctx, cmd, nil) + if err != nil { + return "", err + } + + return strings.TrimSpace(string(stdout)), nil +} + +func (c *UtxoChain) SignRawTransaction(ctx context.Context, keyName string, rawTxHex string) (string, error) { + wallet, err := c.getWalletForUse(keyName) + if err != nil { + return "", err + } + + var cmd []string + if c.WalletVersion >= noDefaultKeyWalletVersion { + cmd = append(c.BaseCli, + fmt.Sprintf("-rpcwallet=%s", keyName), "signrawtransactionwithwallet", rawTxHex) + } else { + // export priv key of sending address + cmd = append(c.BaseCli, "dumpprivkey", wallet.address) + stdout, _, err := c.Exec(ctx, cmd, nil) + if err != nil { + return "", err + } + + // sign raw tx with priv key + cmd = append(c.BaseCli, + "signrawtransaction", rawTxHex, "null", fmt.Sprintf("[\"%s\"]", + strings.TrimSpace(string(stdout)))) + } + + if err := c.LoadWallet(ctx, keyName); err != nil { + return "", err + } + + stdout, _, err := c.Exec(ctx, cmd, nil) + if err != nil { + return "", err + } + + if err := c.UnloadWallet(ctx, keyName); err != nil { + return "", err + } + + var signRawTxOutput SignRawTxOutput + if err := json.Unmarshal(stdout, &signRawTxOutput); err != nil { + return "", err + } + + if !signRawTxOutput.Complete { + c.logger().Error(fmt.Sprintf("Signing transaction did not complete, (%d) errors", len(signRawTxOutput.Errors))) + for i, sErr := range signRawTxOutput.Errors { + c.logger().Error(fmt.Sprintf("Signing error %d: %s", i, sErr.Error)) + } + return "", fmt.Errorf("Sign transaction error on %s", c.cfg.Name) + } + + return signRawTxOutput.Hex, nil +} + +func (c *UtxoChain) SendRawTransaction(ctx context.Context, signedRawTxHex string) (string, error) { + var cmd []string + if c.WalletVersion >= namedFixWalletVersion { + cmd = append(c.BaseCli, "sendrawtransaction", signedRawTxHex) + } else if c.WalletVersion > noDefaultKeyWalletVersion { + cmd = append(c.BaseCli, "sendrawtransaction", signedRawTxHex, "0") + } else { + cmd = append(c.BaseCli, "sendrawtransaction", signedRawTxHex) + } + stdout, _, err := c.Exec(ctx, cmd, nil) + if err != nil { + return "", err + } + + return strings.TrimSpace(string(stdout)), nil +} diff --git a/chain/utxo/default_configs.go b/chain/utxo/default_configs.go new file mode 100644 index 000000000..45af5e7f3 --- /dev/null +++ b/chain/utxo/default_configs.go @@ -0,0 +1,141 @@ +package utxo + +import ( + "fmt" + + "github.com/strangelove-ventures/interchaintest/v8/ibc" +) + +func DefaultBitcoinChainConfig( + name string, + rpcUser string, + rpcPassword string, +) ibc.ChainConfig { + return ibc.ChainConfig{ + Type: "utxo", + Name: name, + ChainID: name, + Bech32Prefix: "n/a", + CoinType: "0", + Denom: "sat", + GasPrices: "0.00001", // min fee / kb + GasAdjustment: 2, // min fee multiplier + TrustingPeriod: "0", + NoHostMount: false, + Images: []ibc.DockerImage{ + { + Repository: "bitcoin/bitcoin", + Version: "26.2", + UidGid: "1025:1025", + }, + }, + Bin: "bitcoind,bitcoin-cli", + AdditionalStartArgs: []string{ + fmt.Sprintf("-rpcuser=%s", rpcUser), + fmt.Sprintf("-rpcpassword=%s", rpcPassword), + "-fallbackfee=0.0005", + "-mintxfee=0.00001", + }, + } +} + +func DefaultBitcoinCashChainConfig( + name string, + rpcUser string, + rpcPassword string, +) ibc.ChainConfig { + return ibc.ChainConfig{ + Type: "utxo", + Name: name, + ChainID: name, + Bech32Prefix: "n/a", + CoinType: "145", + Denom: "sat", + GasPrices: "0.00001", // min fee / kb + GasAdjustment: 2, // min fee multiplier + TrustingPeriod: "0", + NoHostMount: false, + Images: []ibc.DockerImage{ + { + Repository: "zquestz/bitcoin-cash-node", + Version: "27.1.0", + UidGid: "1025:1025", + }, + }, + Bin: "bitcoind,bitcoin-cli", + AdditionalStartArgs: []string{ + fmt.Sprintf("-rpcuser=%s", rpcUser), + fmt.Sprintf("-rpcpassword=%s", rpcPassword), + "-fallbackfee=0.0005", + "-mintxfee=0.00001", + }, + } +} + +func DefaultLitecoinChainConfig( + name string, + rpcUser string, + rpcPassword string, +) ibc.ChainConfig { + return ibc.ChainConfig{ + Type: "utxo", + Name: name, + ChainID: name, + Bech32Prefix: "n/a", + CoinType: "2", + Denom: "sat", + GasPrices: "0.0001", // min fee / kb + GasAdjustment: 2, // min fee multiplier + TrustingPeriod: "0", + NoHostMount: false, + Images: []ibc.DockerImage{ + { + Repository: "uphold/litecoin-core", + Version: "0.21", + UidGid: "1025:1025", + }, + }, + Bin: "litecoind,litecoin-cli", + AdditionalStartArgs: []string{ + fmt.Sprintf("-rpcuser=%s", rpcUser), + fmt.Sprintf("-rpcpassword=%s", rpcPassword), + "-fallbackfee=0.005", + "-mintxfee=0.0001", + }, + } +} + +func DefaultDogecoinChainConfig( + name string, + rpcUser string, + rpcPassword string, +) ibc.ChainConfig { + return ibc.ChainConfig{ + Type: "utxo", + Name: name, + ChainID: name, + Bech32Prefix: "n/a", + CoinType: "3", + Denom: "sat", + GasPrices: "0.01", // min fee / kb + GasAdjustment: 2, // min fee multiplier + TrustingPeriod: "0", + NoHostMount: false, + Images: []ibc.DockerImage{ + { + Repository: "registry.gitlab.com/thorchain/devops/node-launcher", + Version: "dogecoin-daemon-1.14.7", + //Repository: "coinmetrics/dogecoin", + //Version: "1.14.7", + UidGid: "1000:1000", + }, + }, + Bin: "dogecoind,dogecoin-cli", + AdditionalStartArgs: []string{ + fmt.Sprintf("-rpcuser=%s", rpcUser), + fmt.Sprintf("-rpcpassword=%s", rpcPassword), + "-fallbackfee=0.5", + "-mintxfee=0.01", + }, + } +} diff --git a/chain/utxo/types.go b/chain/utxo/types.go new file mode 100644 index 000000000..5e646ef96 --- /dev/null +++ b/chain/utxo/types.go @@ -0,0 +1,62 @@ +package utxo + +type ListReceivedByAddress []ReceivedByAddress + +type ReceivedByAddress struct { + Address string `json:"address"` + Amount float64 `json:"amount"` +} + +type TransactionReceipt struct { + TxHash string `json:"transactionHash"` +} + +type ListUtxo []Utxo + +type Utxo struct { + TxId string `json:"txid,omitempty"` + Vout int `json:"vout,omitempty"` + Address string `json:"address,omitempty"` + Label string `json:"label,omitempty"` + ScriptPubKey string `json:"scriptPubKey,omitempty"` + Amount float64 `json:"amount,omitempty"` + Confirmations int `json:"confirmations,omitempty"` + Spendable bool `json:"spendable,omitempty"` + Solvable bool `json:"solvable,omitempty"` + Desc string `json:"desc,omitempty"` + Safe bool `json:"safe,omitempty"` +} + +type SendInputs []SendInput + +type SendInput struct { + TxId string `json:"txid"` // hex + Vout int `json:"vout"` +} + +type SendOutputs []SendOutput + +type SendOutput struct { + Amount float64 `json:"replaceWithAddress,omitempty"` + Change float64 `json:"replaceWithChangeAddr,omitempty"` + Data string `json:"data,omitempty"` // hex +} + +type SignRawTxError struct { + Txid string `json:"txid"` + Vout int `json:"vout"` + Witness []string `json:"witness"` + ScriptSig string `json:"scriptSig"` + Sequence int `json:"sequence"` + Error string `json:"error"` +} + +type SignRawTxOutput struct { + Hex string `json:"hex"` + Complete bool `json:"complete"` + Errors []SignRawTxError `json:"errors"` +} + +type WalletInfo struct { + WalletVersion int `json:"walletversion"` +} diff --git a/chain/utxo/unimplemented.go b/chain/utxo/unimplemented.go new file mode 100644 index 000000000..5968afc7c --- /dev/null +++ b/chain/utxo/unimplemented.go @@ -0,0 +1,63 @@ +package utxo + +import ( + "context" + "runtime" + + "github.com/strangelove-ventures/interchaintest/v8/ibc" +) + +func PanicFunctionName() { + pc, _, _, _ := runtime.Caller(1) + panic(runtime.FuncForPC(pc).Name() + " not implemented") +} + +func (c *UtxoChain) ExportState(ctx context.Context, height int64) (string, error) { + PanicFunctionName() + return "", nil +} + +func (c *UtxoChain) GetGRPCAddress() string { + PanicFunctionName() + return "" +} + +func (c *UtxoChain) GetHostGRPCAddress() string { + PanicFunctionName() + return "" +} + +func (*UtxoChain) GetHostPeerAddress() string { + PanicFunctionName() + return "" +} + +func (c *UtxoChain) GetGasFeesInNativeDenom(gasPaid int64) int64 { + PanicFunctionName() + return 0 +} + +func (c *UtxoChain) RecoverKey(ctx context.Context, keyName, mnemonic string) error { + PanicFunctionName() + return nil +} + +func (c *UtxoChain) SendIBCTransfer(ctx context.Context, channelID, keyName string, amount ibc.WalletAmount, options ibc.TransferOptions) (ibc.Tx, error) { + PanicFunctionName() + return ibc.Tx{}, nil +} + +func (c *UtxoChain) Acknowledgements(ctx context.Context, height int64) ([]ibc.PacketAcknowledgement, error) { + PanicFunctionName() + return nil, nil +} + +func (c *UtxoChain) Timeouts(ctx context.Context, height int64) ([]ibc.PacketTimeout, error) { + PanicFunctionName() + return nil, nil +} + +func (c *UtxoChain) BuildRelayerWallet(ctx context.Context, keyName string) (ibc.Wallet, error) { + PanicFunctionName() + return nil, nil +} diff --git a/chain/utxo/utxo_chain.go b/chain/utxo/utxo_chain.go new file mode 100644 index 000000000..17d285555 --- /dev/null +++ b/chain/utxo/utxo_chain.go @@ -0,0 +1,520 @@ +package utxo + +import ( + "context" + + "fmt" + "io" + "math" + "time" + + "strconv" + "strings" + + sdkmath "cosmossdk.io/math" + dockertypes "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/api/types/volume" + dockerclient "github.com/docker/docker/client" + "github.com/docker/go-connections/nat" + "github.com/strangelove-ventures/interchaintest/v8/dockerutil" + "github.com/strangelove-ventures/interchaintest/v8/ibc" + "github.com/strangelove-ventures/interchaintest/v8/testutil" + "go.uber.org/zap" +) + +var _ ibc.Chain = &UtxoChain{} + +const ( + blockTime = 2 // seconds + rpcPort = "18443/tcp" + noDefaultKeyWalletVersion = 159_900 + namedFixWalletVersion = 169_901 +) + +var natPorts = nat.PortMap{ + nat.Port(rpcPort): {}, +} + +type UtxoChain struct { + testName string + cfg ibc.ChainConfig + + log *zap.Logger + + VolumeName string + NetworkID string + DockerClient *dockerclient.Client + + containerLifecycle *dockerutil.ContainerLifecycle + + hostRPCPort string + + // cli arguments + BinDaemon string + BinCli string + RpcUser string + RpcPassword string + BaseCli []string + AddrToKeyNameMap map[string]string + KeyNameToWalletMap map[string]*NodeWallet + + WalletVersion int + unloadWalletAfterUse bool +} + +func NewUtxoChain(testName string, chainConfig ibc.ChainConfig, log *zap.Logger) *UtxoChain { + bins := strings.Split(chainConfig.Bin, ",") + if len(bins) != 2 { + panic(fmt.Sprintf("%s chain must set the daemon and cli binaries (i.e. appd,app-cli)", chainConfig.Name)) + } + rpcUser := "" + rpcPassword := "" + for _, arg := range chainConfig.AdditionalStartArgs { + if strings.Contains(arg, "-rpcuser") { + rpcUser = arg + } + if strings.Contains(arg, "-rpcpassword") { + rpcPassword = arg + } + } + if rpcUser == "" || rpcPassword == "" { + panic(fmt.Sprintf("%s chain must have -rpcuser and -rpcpassword set in config's AdditionalStartArgs", chainConfig.Name)) + } + + return &UtxoChain{ + testName: testName, + cfg: chainConfig, + log: log, + BinDaemon: bins[0], + BinCli: bins[1], + RpcUser: rpcUser, + RpcPassword: rpcPassword, + AddrToKeyNameMap: make(map[string]string), + KeyNameToWalletMap: make(map[string]*NodeWallet), + unloadWalletAfterUse: false, + } +} + +func (c *UtxoChain) Config() ibc.ChainConfig { + return c.cfg +} + +func (c *UtxoChain) Initialize(ctx context.Context, testName string, cli *dockerclient.Client, networkID string) error { + chainCfg := c.Config() + c.pullImages(ctx, cli) + image := chainCfg.Images[0] + + c.containerLifecycle = dockerutil.NewContainerLifecycle(c.log, cli, c.Name()) + + v, err := cli.VolumeCreate(ctx, volume.CreateOptions{ + Labels: map[string]string{ + dockerutil.CleanupLabel: testName, + + dockerutil.NodeOwnerLabel: c.Name(), + }, + }) + if err != nil { + return fmt.Errorf("creating volume for chain node: %w", err) + } + c.VolumeName = v.Name + c.NetworkID = networkID + c.DockerClient = cli + + if err := dockerutil.SetVolumeOwner(ctx, dockerutil.VolumeOwnerOptions{ + Log: c.log, + + Client: cli, + + VolumeName: v.Name, + ImageRef: image.Ref(), + TestName: testName, + UidGid: image.UidGid, + }); err != nil { + return fmt.Errorf("set volume owner: %w", err) + } + + return nil +} + +func (c *UtxoChain) Name() string { + return fmt.Sprintf("utxo-%s-%s", c.cfg.ChainID, dockerutil.SanitizeContainerName(c.testName)) +} + +func (c *UtxoChain) HomeDir() string { + return "/home/utxo" +} + +func (c *UtxoChain) Bind() []string { + return []string{fmt.Sprintf("%s:%s", c.VolumeName, c.HomeDir())} +} + +func (c *UtxoChain) pullImages(ctx context.Context, cli *dockerclient.Client) { + for _, image := range c.Config().Images { + rc, err := cli.ImagePull( + ctx, + image.Repository+":"+image.Version, + dockertypes.ImagePullOptions{}, + ) + if err != nil { + c.log.Error("Failed to pull image", + zap.Error(err), + zap.String("repository", image.Repository), + zap.String("tag", image.Version), + ) + } else { + _, _ = io.Copy(io.Discard, rc) + _ = rc.Close() + } + } +} + +func (c *UtxoChain) Start(testName string, ctx context.Context, additionalGenesisWallets ...ibc.WalletAmount) error { + cmd := []string{c.BinDaemon, + "--regtest", + "-printtoconsole", + "-regtest=1", + "-txindex", + "-rpcallowip=0.0.0.0/0", + "-rpcbind=0.0.0.0", + "-deprecatedrpc=create_bdb", + "-rpcport=18443", + } + + cmd = append(cmd, c.cfg.AdditionalStartArgs...) + + usingPorts := nat.PortMap{} + for k, v := range natPorts { + usingPorts[k] = v + } + + if c.cfg.HostPortOverride != nil { + for intP, extP := range c.cfg.HostPortOverride { + usingPorts[nat.Port(fmt.Sprintf("%d/tcp", intP))] = []nat.PortBinding{ + { + HostPort: fmt.Sprintf("%d", extP), + }, + } + } + + fmt.Printf("Port Overrides: %v. Using: %v\n", c.cfg.HostPortOverride, usingPorts) + } + + env := []string{} + if c.cfg.Images[0].UidGid != "" { + uidGid := strings.Split(c.cfg.Images[0].UidGid, ":") + if len(uidGid) != 2 { + panic(fmt.Sprintf("%s chain does not have valid UidGid", c.cfg.Name)) + } + env = []string{ + fmt.Sprintf("UID=%s", uidGid[0]), + fmt.Sprintf("GID=%s", uidGid[1]), + } + } + + entrypoint := []string{"/entrypoint.sh"} + if c.cfg.Images[0].Repository == "registry.gitlab.com/thorchain/devops/node-launcher" { // these images don't have "/entrypoint.sh" + entrypoint = []string{} + cmd = append(cmd, fmt.Sprintf("--datadir=%s", c.HomeDir())) + } + + err := c.containerLifecycle.CreateContainer(ctx, c.testName, c.NetworkID, c.cfg.Images[0], + usingPorts, c.Bind(), []mount.Mount{}, c.HostName(), cmd, env, entrypoint) + if err != nil { + return err + } + + c.log.Info("Starting container", zap.String("container", c.Name())) + + if err := c.containerLifecycle.StartContainer(ctx); err != nil { + return err + } + + hostPorts, err := c.containerLifecycle.GetHostPorts(ctx, rpcPort) + if err != nil { + return err + } + + c.hostRPCPort = strings.Split(hostPorts[0], ":")[1] + + c.BaseCli = []string{ + c.BinCli, + "-regtest", + c.RpcUser, + c.RpcPassword, + fmt.Sprintf("-rpcconnect=%s", c.HostName()), + "-rpcport=18443", + } + + // Wait for rpc to come up + time.Sleep(time.Second * 5) + + go func() { + ctx := context.Background() + amount := "100" + nextBlockHeight := 100 + if c.cfg.CoinType == "3" { + amount = "1000" // Dogecoin needs more blocks for more coins + nextBlockHeight = 1000 + } + for { + faucetWallet, found := c.KeyNameToWalletMap["faucet"] + if !found || !faucetWallet.ready { + time.Sleep(time.Second) + continue + } + + // If faucet exists, chain is up and running. Any future error should return from this go routine. + // If the chain stops, we will then error and return from this go routine + // Don't use ctx from Start(), it gets cancelled soon after returning. + cmd = append(c.BaseCli, "generatetoaddress", amount, faucetWallet.address) + _, _, err = c.Exec(ctx, cmd, nil) + if err != nil { + c.logger().Error("generatetoaddress error", zap.Error(err)) + return + } + amount = "1" + if nextBlockHeight == 431 && c.cfg.CoinType == "2" { + keyName := "mweb" + if err := c.CreateWallet(ctx, keyName); err != nil { + c.logger().Error("error creating mweb wallet at block 431", zap.String("chain", c.cfg.ChainID), zap.Error(err)) + return + } + addr, err := c.GetNewAddress(ctx, keyName, true) + if err != nil { + c.logger().Error("error creating mweb wallet at block 431", zap.String("chain", c.cfg.ChainID), zap.Error(err)) + return + } + if err := c.sendToMwebAddress(ctx, "faucet", addr, 1); err != nil { + c.logger().Error("error sending to mweb wallet at block 431", zap.String("chain", c.cfg.ChainID), zap.Error(err)) + return + } + } + nextBlockHeight++ + time.Sleep(time.Second * 2) + } + }() + + c.WalletVersion, _ = c.GetWalletVersion(ctx, "") + + keyName := "faucet" + if err := c.CreateWallet(ctx, keyName); err != nil { + return err + } + + if c.WalletVersion == 0 { + c.WalletVersion, err = c.GetWalletVersion(ctx, keyName) + if err != nil { + return err + } + } + + addr, err := c.GetNewAddress(ctx, keyName, false) + if err != nil { + return err + } + + if err := c.SetAccount(ctx, addr, keyName); err != nil { + return err + } + + // Wait for 100 blocks to be created, coins mature after 100 blocks and the faucet starts getting 50 spendable coins/block onwards + // Then wait the standard 2 blocks which also gives the faucet a starting balance of 100 coins + for height, err := c.Height(ctx); err == nil && height < int64(102); { + time.Sleep(time.Second) + height, err = c.Height(ctx) + } + return err +} + +func (c *UtxoChain) HostName() string { + return dockerutil.CondenseHostName(c.Name()) +} + +func (c *UtxoChain) Exec(ctx context.Context, cmd []string, env []string) (stdout, stderr []byte, err error) { + logger := zap.NewNop() + if cmd[len(cmd)-1] != "getblockcount" && cmd[len(cmd)-3] != "generatetoaddress" { // too much logging, maybe switch to an rpc lib in the future + logger = c.logger() + } + job := dockerutil.NewImage(logger, c.DockerClient, c.NetworkID, c.testName, c.cfg.Images[0].Repository, c.cfg.Images[0].Version) + opts := dockerutil.ContainerOptions{ + Env: env, + Binds: c.Bind(), + } + res := job.Run(ctx, cmd, opts) + return res.Stdout, res.Stderr, res.Err +} + +func (c *UtxoChain) logger() *zap.Logger { + return c.log.With( + zap.String("chain_id", c.cfg.ChainID), + zap.String("test", c.testName), + ) +} + +func (c *UtxoChain) GetRPCAddress() string { + return fmt.Sprintf("http://%s:18443", c.HostName()) +} + +func (c *UtxoChain) GetWSAddress() string { + return fmt.Sprintf("ws://%s:18443", c.HostName()) +} + +func (c *UtxoChain) GetHostRPCAddress() string { + return "http://0.0.0.0:" + c.hostRPCPort +} + +func (c *UtxoChain) GetHostWSAddress() string { + return "ws://0.0.0.0:" + c.hostRPCPort +} + +func (c *UtxoChain) CreateKey(ctx context.Context, keyName string) error { + if keyName == "faucet" { + // chain has not started, cannot create wallet yet. Faucet will be created in Start(). + return nil + } + + if err := c.CreateWallet(ctx, keyName); err != nil { + return err + } + + addr, err := c.GetNewAddress(ctx, keyName, false) + if err != nil { + return err + } + + return c.SetAccount(ctx, addr, keyName) +} + +// Get address of account, cast to a string to use +func (c *UtxoChain) GetAddress(ctx context.Context, keyName string) ([]byte, error) { + wallet, ok := c.KeyNameToWalletMap[keyName] + if ok { + return []byte(wallet.address), nil + } + + // Pre-start GetAddress doesn't matter + if keyName == "faucet" { + return []byte(keyName), nil + } + + return nil, fmt.Errorf("Keyname: %s's address not found", keyName) +} + +func (c *UtxoChain) SendFunds(ctx context.Context, keyName string, amount ibc.WalletAmount) error { + _, err := c.SendFundsWithNote(ctx, keyName, amount, "") + return err +} + +func (c *UtxoChain) SendFundsWithNote(ctx context.Context, keyName string, amount ibc.WalletAmount, note string) (string, error) { + partialCoin := amount.Amount.ModRaw(int64(math.Pow10(int(*c.Config().CoinDecimals)))) + fullCoins := amount.Amount.Sub(partialCoin).QuoRaw(int64(math.Pow10(int(*c.Config().CoinDecimals)))) + sendAmountFloat := float64(fullCoins.Int64()) + float64(partialCoin.Int64())/math.Pow10(int(*c.Config().CoinDecimals)) + + if err := c.LoadWallet(ctx, keyName); err != nil { + return "", err + } + + // get utxo + listUtxo, err := c.ListUnspent(ctx, keyName) + if err != nil { + return "", err + } + + rawTxHex, err := c.CreateRawTransaction(ctx, keyName, listUtxo, amount.Address, sendAmountFloat, []byte(note)) + if err != nil { + return "", err + } + + // sign raw transaction + signedRawTxHex, err := c.SignRawTransaction(ctx, keyName, rawTxHex) + if err != nil { + return "", err + } + + // send raw transaction + txHash, err := c.SendRawTransaction(ctx, signedRawTxHex) + if err != nil { + return "", err + } + + if err := c.UnloadWallet(ctx, keyName); err != nil { + return "", err + } + + err = testutil.WaitForBlocks(ctx, 1, c) + return txHash, err +} + +func (c *UtxoChain) Height(ctx context.Context) (int64, error) { + cmd := append(c.BaseCli, "getblockcount") + stdout, _, err := c.Exec(ctx, cmd, nil) + if err != nil { + return 0, err + } + + return strconv.ParseInt(strings.TrimSpace(string(stdout)), 10, 64) +} + +func (c *UtxoChain) GetBalance(ctx context.Context, address string, denom string) (sdkmath.Int, error) { + keyName, ok := c.AddrToKeyNameMap[address] + if !ok { + return sdkmath.Int{}, fmt.Errorf("wallet not found for address: %s", address) + } + + balance := "" + var coinsWithDecimal float64 + if c.WalletVersion >= noDefaultKeyWalletVersion { + if err := c.LoadWallet(ctx, keyName); err != nil { + return sdkmath.Int{}, err + } + cmd := append(c.BaseCli, fmt.Sprintf("-rpcwallet=%s", keyName), "getbalance") + stdout, _, err := c.Exec(ctx, cmd, nil) + if err != nil { + return sdkmath.Int{}, err + } + if err := c.UnloadWallet(ctx, keyName); err != nil { + return sdkmath.Int{}, err + } + balance = strings.TrimSpace(string(stdout)) + coinsWithDecimal, err = strconv.ParseFloat(balance, 64) + if err != nil { + return sdkmath.Int{}, err + } + } else { + listUtxo, err := c.ListUnspent(ctx, keyName) + if err != nil { + return sdkmath.Int{}, err + } + + for _, utxo := range listUtxo { + if utxo.Spendable { + coinsWithDecimal += utxo.Amount + } + } + } + + coinsScaled := int64(coinsWithDecimal * math.Pow10(int(*c.Config().CoinDecimals))) + return sdkmath.NewInt(coinsScaled), nil +} + +func (c *UtxoChain) BuildWallet(ctx context.Context, keyName string, mnemonic string) (ibc.Wallet, error) { + if mnemonic != "" { + err := c.RecoverKey(ctx, keyName, mnemonic) + if err != nil { + return nil, err + } + } else { + // Create new account + err := c.CreateKey(ctx, keyName) + if err != nil { + return nil, err + } + } + + address, err := c.GetAddress(ctx, keyName) + if err != nil { + return nil, err + } + return NewWallet(keyName, string(address)), nil +} diff --git a/chain/utxo/wallet.go b/chain/utxo/wallet.go new file mode 100644 index 000000000..26c00d2b0 --- /dev/null +++ b/chain/utxo/wallet.go @@ -0,0 +1,107 @@ +package utxo + +import ( + "fmt" + "sync" + + "github.com/strangelove-ventures/interchaintest/v8/ibc" +) + +var _ ibc.Wallet = &UtxoWallet{} + +type UtxoWallet struct { + address string + keyName string +} + +func NewWallet(keyname string, address string) ibc.Wallet { + return &UtxoWallet{ + address: address, + keyName: keyname, + } +} + +func (w *UtxoWallet) KeyName() string { + return w.keyName +} + +// Get formatted address, passing in a prefix +func (w *UtxoWallet) FormattedAddress() string { + return w.address +} + +// Get mnemonic, only used for relayer wallets +func (w *UtxoWallet) Mnemonic() string { + return "" +} + +// Get Address with chain's prefix +func (w *UtxoWallet) Address() []byte { + return []byte(w.address) +} + +type NodeWallet struct { + keyName string + address string + mu sync.Mutex + loadCount int + ready bool +} + +func (c *UtxoChain) getWalletForNewAddress(keyName string) (*NodeWallet, error) { + wallet, found := c.KeyNameToWalletMap[keyName] + if c.WalletVersion >= noDefaultKeyWalletVersion { + if !found { + return nil, fmt.Errorf("Wallet keyname (%s) not found, has it been created?", keyName) + } + if wallet.address != "" { + return nil, fmt.Errorf("Wallet keyname (%s) already has an address", keyName) + } + } + + if c.WalletVersion < noDefaultKeyWalletVersion { + if found { + return nil, fmt.Errorf("Wallet keyname (%s) already has an address", keyName) + } else { + wallet = &NodeWallet{ + keyName: keyName, + } + c.KeyNameToWalletMap[keyName] = wallet + } + } + + return wallet, nil +} + +func (c *UtxoChain) getWalletForSetAccount(keyName string, addr string) (*NodeWallet, error) { + wallet, found := c.KeyNameToWalletMap[keyName] + if !found { + return nil, fmt.Errorf("Wallet keyname (%s) not found, get new address not called", keyName) + } + if wallet.address != addr { + return nil, fmt.Errorf("Wallet keyname (%s) is associated with address (%s), not (%s)", keyName, wallet.address, addr) + } + return wallet, nil +} + +func (c *UtxoChain) getWalletForUse(keyName string) (*NodeWallet, error) { + wallet, err := c.getWallet(keyName) + if err != nil { + return nil, err + } + // Verifies wallet has expected state on node + // For chain without wallet support, GetNewAddress() and SetAccount() must be called. + // For chains with wallet support, CreateWallet() and GetNewAddress() must be called. + if !wallet.ready { + return nil, fmt.Errorf("Wallet keyname (%s) is not ready for use, check creation steps", keyName) + } + return wallet, nil +} + +func (c *UtxoChain) getWallet(keyName string) (*NodeWallet, error) { + wallet, found := c.KeyNameToWalletMap[keyName] + if !found { + return nil, fmt.Errorf("Wallet keyname (%s) not found", keyName) + } + return wallet, nil +} diff --git a/chainfactory.go b/chainfactory.go index 783c10f96..631f3f004 100644 --- a/chainfactory.go +++ b/chainfactory.go @@ -11,6 +11,7 @@ import ( "github.com/strangelove-ventures/interchaintest/v8/chain/ethereum" "github.com/strangelove-ventures/interchaintest/v8/chain/penumbra" "github.com/strangelove-ventures/interchaintest/v8/chain/polkadot" + "github.com/strangelove-ventures/interchaintest/v8/chain/utxo" "github.com/strangelove-ventures/interchaintest/v8/ibc" "go.uber.org/zap" "gopkg.in/yaml.v3" @@ -156,6 +157,8 @@ func buildChain(log *zap.Logger, testName string, cfg ibc.ChainConfig, numValida } case "ethereum": return ethereum.NewEthereumChain(testName, cfg, log), nil + case "utxo": + return utxo.NewUtxoChain(testName, cfg, log), nil default: return nil, fmt.Errorf("unexpected error, unknown chain type: %s for chain: %s", cfg.Type, cfg.Name) } diff --git a/chainspec.go b/chainspec.go index 410cdabb5..945ca5196 100644 --- a/chainspec.go +++ b/chainspec.go @@ -162,17 +162,15 @@ func (s *ChainSpec) applyConfigOverrides(cfg ibc.ChainConfig) (*ibc.ChainConfig, if cfg.CoinDecimals == nil { evm := int64(18) cosmos := int64(6) + bitcoin := int64(8) switch cfg.CoinType { + case "0", "2", "3", "145": + cfg.CoinDecimals = &bitcoin case "60": cfg.CoinDecimals = &evm - case "118": + case "118", "330", "529": cfg.CoinDecimals = &cosmos - case "330": - cfg.CoinDecimals = &cosmos - case "529": - cfg.CoinDecimals = &cosmos - } } diff --git a/dockerutil/container_lifecycle.go b/dockerutil/container_lifecycle.go index a466488fe..b89d06acc 100644 --- a/dockerutil/container_lifecycle.go +++ b/dockerutil/container_lifecycle.go @@ -45,6 +45,7 @@ func (c *ContainerLifecycle) CreateContainer( hostName string, cmd []string, env []string, + entrypoint []string, ) error { imageRef := image.Ref() c.log.Info( @@ -75,7 +76,7 @@ func (c *ContainerLifecycle) CreateContainer( &container.Config{ Image: imageRef, - Entrypoint: []string{}, + Entrypoint: entrypoint, Cmd: cmd, Env: env, diff --git a/examples/ethereum/start_test.go b/examples/ethereum/start_test.go index 5233777e1..01c8db5d0 100644 --- a/examples/ethereum/start_test.go +++ b/examples/ethereum/start_test.go @@ -101,11 +101,12 @@ func TestEthereum(t *testing.T) { // Fund user2 wallet using SendFunds() from user1 wallet ethUser2InitialAmount := math.NewInt(ethereum.ETHER) - ethereumChain.SendFunds(ctx, ethUser.KeyName(), ibc.WalletAmount{ + err = ethereumChain.SendFunds(ctx, ethUser.KeyName(), ibc.WalletAmount{ Address: ethUser2.FormattedAddress(), Denom: ethereumChain.Config().Denom, Amount: ethUser2InitialAmount, }) + require.NoError(t, err) // Final check of balances balance, err = ethereumChain.GetBalance(ctx, faucetAddr, "") diff --git a/examples/utxo/start_test.go b/examples/utxo/start_test.go new file mode 100644 index 000000000..d8f8bcc56 --- /dev/null +++ b/examples/utxo/start_test.go @@ -0,0 +1,132 @@ +package utxo_test + +import ( + "context" + "fmt" + "math" + "strconv" + "testing" + + sdkmath "cosmossdk.io/math" + "github.com/strangelove-ventures/interchaintest/v8" + "github.com/strangelove-ventures/interchaintest/v8/chain/utxo" + "github.com/strangelove-ventures/interchaintest/v8/ibc" + "golang.org/x/sync/errgroup" + + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" +) + +func TestUtxo(t *testing.T) { + + if testing.Short() { + t.Skip() + } + + t.Parallel() + + client, network := interchaintest.DockerSetup(t) + ctx := context.Background() + + // Get default bitcoin chain config + btcConfig := utxo.DefaultBitcoinChainConfig("btc", "rpcuser", "password") + bchConfig := utxo.DefaultBitcoinCashChainConfig("bch", "rpcuser", "password") + liteConfig := utxo.DefaultLitecoinChainConfig("ltc", "rpcuser", "password") + dogeConfig := utxo.DefaultDogecoinChainConfig("doge", "rpcuser", "password") + + cf := interchaintest.NewBuiltinChainFactory(zaptest.NewLogger(t), []*interchaintest.ChainSpec{ + {ChainConfig: btcConfig}, + {ChainConfig: bchConfig}, + {ChainConfig: liteConfig}, + {ChainConfig: dogeConfig}, + }) + + chains, err := cf.Chains(t.Name()) + require.NoError(t, err) + + ic := interchaintest.NewInterchain() + for _, chain := range chains { + ic.AddChain(chain) + } + + require.NoError(t, ic.Build(ctx, nil, interchaintest.InterchainBuildOptions{ + TestName: t.Name(), + Client: client, + NetworkID: network, + SkipPathCreation: true, // Skip path creation, so we can have granular control over the process + })) + t.Cleanup(func() { + _ = ic.Close() + }) + + // Create and fund a user using GetAndFundTestUsers + eg, egCtx := errgroup.WithContext(ctx) + for _, chain := range chains { + chain := chain + eg.Go(func() error { + // Fund 2 coins to user1 and user2 + fundAmount := sdkmath.NewInt(200_000_000) + users := interchaintest.GetAndFundTestUsers(t, egCtx, "user1", fundAmount, chain) + user1 := users[0] + users = interchaintest.GetAndFundTestUsers(t, egCtx, "user2", fundAmount, chain) + user2 := users[0] + + // Verify user1 balance + balanceUser1, err := chain.GetBalance(egCtx, user1.FormattedAddress(), "") + if err != nil { + return err + } + if !balanceUser1.Equal(fundAmount) { + return fmt.Errorf("User (%s) balance (%s) is not expected (%s)", user1.KeyName(), balanceUser1, fundAmount) + } + + // Verify user2 balance + balanceUser2, err := chain.GetBalance(ctx, user2.FormattedAddress(), "") + if err != nil { + return err + } + if !balanceUser2.Equal(fundAmount) { + return fmt.Errorf("User (%s) balance (%s) is not expected (%s)", user2.KeyName(), balanceUser2, fundAmount) + } + + // Send 1 coin from user1 to user2 with a note/memo + memo := fmt.Sprintf("+:%s:%s", "abc.abc", "bech16sg0fxrdd0vgpl4pkcnqwzjlu5lrs6ymcqldel") + transferAmount := sdkmath.NewInt(100_000_000) + _, err = chain.SendFundsWithNote(ctx, user1.KeyName(), ibc.WalletAmount{ + Address: user2.FormattedAddress(), + Amount: transferAmount, + }, memo) + if err != nil { + return err + } + + // Verify user1 balance + balanceUser1, err = chain.GetBalance(egCtx, user1.FormattedAddress(), "") + if err != nil { + return err + } + fees, err := strconv.ParseFloat(chain.Config().GasPrices, 64) + if err != nil { + return err + } + feeScaled := fees * chain.Config().GasAdjustment * math.Pow10(int(*chain.Config().CoinDecimals)) + expectedBalance := fundAmount.Sub(transferAmount).SubRaw(int64(feeScaled)) + if !balanceUser1.Equal(expectedBalance) { + return fmt.Errorf("User (%s) balance (%s) is not expected (%s)", user1.KeyName(), balanceUser1, expectedBalance) + } + + // Verify user2 balance + balanceUser2, err = chain.GetBalance(ctx, user2.FormattedAddress(), "") + if err != nil { + return err + } + expectedBalance = fundAmount.Add(transferAmount) + if !balanceUser2.Equal(expectedBalance) { + return fmt.Errorf("User (%s) balance (%s) is not expected (%s)", user2.KeyName(), balanceUser2, expectedBalance) + } + + return nil + }) + } + require.NoError(t, eg.Wait()) +} diff --git a/ibc/chain.go b/ibc/chain.go index 74122ddcc..780400b3d 100644 --- a/ibc/chain.go +++ b/ibc/chain.go @@ -61,6 +61,9 @@ type Chain interface { // SendFunds sends funds to a wallet from a user account. SendFunds(ctx context.Context, keyName string, amount WalletAmount) error + // SendFundsWithNote sends funds to a wallet from a user account with a note/memo + SendFundsWithNote(ctx context.Context, keyName string, amount WalletAmount, note string) (string, error) + // SendIBCTransfer sends an IBC transfer returning a transaction or an error if the transfer failed. SendIBCTransfer(ctx context.Context, channelID, keyName string, amount WalletAmount, options TransferOptions) (Tx, error) diff --git a/relayer/docker.go b/relayer/docker.go index 39eb8270e..7db3a95fb 100644 --- a/relayer/docker.go +++ b/relayer/docker.go @@ -370,7 +370,7 @@ func (r *DockerRelayer) StartRelayer(ctx context.Context, rep ibc.RelayerExecRep if err := r.containerLifecycle.CreateContainer( ctx, r.testName, r.networkID, containerImage, nil, - r.Bind(), nil, r.HostName(joinedPaths), cmd, nil, + r.Bind(), nil, r.HostName(joinedPaths), cmd, nil, []string{}, ); err != nil { return err }