diff --git a/.gitpod.yml b/.gitpod.yml index 0a302b3ee9..dd57e50e1e 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -38,3 +38,4 @@ ports: - port: 26657 - port: 8080 - port: 7575 + - port: 4500 diff --git a/go.mod b/go.mod index a31380e9a7..a0eb135381 100644 --- a/go.mod +++ b/go.mod @@ -49,7 +49,7 @@ require ( golang.org/x/mod v0.4.0 golang.org/x/net v0.0.0-20201021035429-f5854403a974 // indirect golang.org/x/sync v0.0.0-20201207232520-09787c993a3a - golang.org/x/sys v0.0.0-20210113000019-eaf3bda374d2 // indirect + golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78 // indirect golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf // indirect google.golang.org/genproto v0.0.0-20201019141844-1ed22bb0c154 // indirect google.golang.org/grpc v1.33.0 diff --git a/go.sum b/go.sum index 0a8e9f8b97..9c4a0bf931 100644 --- a/go.sum +++ b/go.sum @@ -928,8 +928,8 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210113000019-eaf3bda374d2 h1:F9vNgpIiamoF+Q1/c78bikg/NScXEtbZSNEpnRelOzs= -golang.org/x/sys v0.0.0-20210113000019-eaf3bda374d2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78 h1:nVuTkr9L6Bq62qpUqKo/RnZCFfzDBL0bYo6w9OJUqZY= +golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M= golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/starport/interface/cli/starport/cmd/cmd.go b/starport/interface/cli/starport/cmd/cmd.go index 574e218813..26c7ecbaf2 100644 --- a/starport/interface/cli/starport/cmd/cmd.go +++ b/starport/interface/cli/starport/cmd/cmd.go @@ -92,7 +92,7 @@ func newChainWithHomeFlags(cmd *cobra.Command, appPath string, chainOption ...ch chainOption = append(chainOption, chain.CLIHomePath(cliHome)) } - return chain.New(appPath, chainOption...) + return chain.New(cmd.Context(), appPath, chainOption...) } func initOptionWithHomeFlags(cmd *cobra.Command, initOptions []networkbuilder.InitOption) ([]networkbuilder.InitOption, error) { diff --git a/starport/pkg/chaincmd/chaincmd.go b/starport/pkg/chaincmd/chaincmd.go index 91bbeb72bd..3daa7e90c0 100644 --- a/starport/pkg/chaincmd/chaincmd.go +++ b/starport/pkg/chaincmd/chaincmd.go @@ -16,6 +16,9 @@ const ( commandCollectGentxs = "collect-gentxs" commandValidateGenesis = "validate-genesis" commandShowNodeID = "show-node-id" + commandStatus = "status" + commandTx = "tx" + commandQuery = "query" optionHome = "--home" optionKeyringBackend = "--keyring-backend" @@ -30,6 +33,7 @@ const ( optionValidatorCommissionMaxChangeRate = "--commission-max-change-rate" optionValidatorMinSelfDelegation = "--min-self-delegation" optionValidatorGasPrices = "--gas-prices" + optionYes = "--yes" optionHomeClient = "--home-client" constTendermint = "tendermint" @@ -48,34 +52,48 @@ const ( ) type ChainCmd struct { - appCmd string - chainID string - homeDir string - keyringBackend KeyringBackend - cliCmd string - cliHome string + appCmd string + chainID string + homeDir string + keyringBackend KeyringBackend + keyringPassword string + cliCmd string + cliHome string + nodeAddress string + + isAutoChainIDDetectionEnabled bool sdkVersion cosmosver.Version } // New creates a new ChainCmd to launch command with the chain app func New(appCmd string, options ...Option) ChainCmd { - chainCmd := ChainCmd{ + c := ChainCmd{ appCmd: appCmd, sdkVersion: cosmosver.Versions.Latest(), } - // Apply the options provided by the user - for _, applyOption := range options { - applyOption(&chainCmd) - } + applyOptions(&c, options) - return chainCmd + return c +} + +// Copy makes a copy of ChainCmd by overwriting its options with given options. +func (c ChainCmd) Copy(options ...Option) ChainCmd { + applyOptions(&c, options) + + return c } // Option configures ChainCmd. type Option func(*ChainCmd) +func applyOptions(c *ChainCmd, options []Option) { + for _, applyOption := range options { + applyOption(c) + } +} + // WithVersion sets the version of the blockchain. // when this is not provided, latest version of SDK is assumed. func WithVersion(v cosmosver.Version) Option { @@ -98,6 +116,13 @@ func WithChainID(chainID string) Option { } } +// WithAutoChainIDDetection finds out the chain id by communicating with the node running. +func WithAutoChainIDDetection() Option { + return func(c *ChainCmd) { + c.isAutoChainIDDetectionEnabled = true + } +} + // WithKeyringBackend provides a specific keyring backend for the commands that accept this option func WithKeyringBackend(keyringBackend KeyringBackend) Option { return func(c *ChainCmd) { @@ -105,6 +130,37 @@ func WithKeyringBackend(keyringBackend KeyringBackend) Option { } } +// WithKeyringPassword provides a password to unlock keyring +func WithKeyringPassword(password string) Option { + return func(c *ChainCmd) { + c.keyringPassword = password + } +} + +// WitNodeAddress sets the node address for the commands that needs to make an +// API request to the node that has a different node address other than the default one. +func WitNodeAddress(addr string) Option { + return func(c *ChainCmd) { + c.nodeAddress = addr + } +} + +// WithLaunchpadCLI provides the CLI application name for the blockchain +// this is necessary for Launchpad applications since it has two different binaries but +// not needed by Stargate applications +func WithLaunchpadCLI(cliCmd string) Option { + return func(c *ChainCmd) { + c.cliCmd = cliCmd + } +} + +// WithLaunchpadCLIHome replaces the default home used by the Launchpad chain CLI +func WithLaunchpadCLIHome(cliHome string) Option { + return func(c *ChainCmd) { + c.cliHome = cliHome + } +} + // StartCommand returns the command to start the daemon of the chain func (c ChainCmd) StartCommand(options ...string) step.Option { command := append([]string{ @@ -249,6 +305,14 @@ func GentxWithGasPrices(gasPrices string) GentxOption { } } +func (c ChainCmd) IsAutoChainIDDetectionEnabled() bool { + return c.isAutoChainIDDetectionEnabled +} + +func (c ChainCmd) SDKVersion() cosmosver.Version { + return c.sdkVersion +} + // GentxCommand returns the command to generate a gentx for the chain func (c ChainCmd) GentxCommand( validatorName string, @@ -328,6 +392,52 @@ func (c ChainCmd) ShowNodeIDCommand() step.Option { return c.daemonCommand(command) } +// BankSendCommand returns the command for transferring tokens. +func (c ChainCmd) BankSendCommand(fromAddress, toAddress, amount string) step.Option { + command := []string{ + commandTx, + } + + if c.sdkVersion.Major().Is(cosmosver.Stargate) { + command = append(command, + "bank", + ) + } + + command = append(command, + "send", + fromAddress, + toAddress, + amount, + optionYes, + ) + + command = c.attachChainID(command) + command = c.attachKeyringBackend(command) + + return c.cliCommand(command) +} + +// QueryTxEventsCommand returns the command to query events. +func (c ChainCmd) QueryTxEventsCommand(query string) step.Option { + command := []string{ + commandQuery, + "txs", + "--events", + query, + "--page", "1", + "--limit", "1000", + } + + if c.sdkVersion.Major().Is(cosmosver.Launchpad) { + command = append(command, + "--trust-node", + ) + } + + return c.cliCommand(command) +} + // LaunchpadSetConfigCommand returns the command to set config value func (c ChainCmd) LaunchpadSetConfigCommand(name string, value string) step.Option { // Check version @@ -346,6 +456,15 @@ func (c ChainCmd) LaunchpadRestServerCommand(apiAddress string, rpcAddress strin return c.launchpadRestServerCommand(apiAddress, rpcAddress) } +// StatusCommand returns the command that fetches node's status. +func (c ChainCmd) StatusCommand() step.Option { + command := []string{ + commandStatus, + } + + return c.cliCommand(command) +} + // attachChainID appends the chain ID flag to the provided command func (c ChainCmd) attachChainID(command []string) []string { if c.chainID != "" { diff --git a/starport/pkg/chaincmd/launchpad.go b/starport/pkg/chaincmd/launchpad.go index 517abbeabf..d748196aa4 100644 --- a/starport/pkg/chaincmd/launchpad.go +++ b/starport/pkg/chaincmd/launchpad.go @@ -12,22 +12,6 @@ const ( optionName = "--name" ) -// WithLaunchpadCLI provides the CLI application name for the blockchain -// this is necessary for Launchpad applications since it has two different binaries but -// not needed by Stargate applications -func WithLaunchpadCLI(cliCmd string) Option { - return func(c *ChainCmd) { - c.cliCmd = cliCmd - } -} - -// WithLaunchpadCLIHome replaces the default home used by the Launchpad chain CLI -func WithLaunchpadCLIHome(cliHome string) Option { - return func(c *ChainCmd) { - c.cliHome = cliHome - } -} - // launchpadSetConfigCommand func (c ChainCmd) launchpadSetConfigCommand(name string, value string) step.Option { command := []string{ diff --git a/starport/pkg/chaincmd/runner/account.go b/starport/pkg/chaincmd/runner/account.go index 75c6399d75..972d690069 100644 --- a/starport/pkg/chaincmd/runner/account.go +++ b/starport/pkg/chaincmd/runner/account.go @@ -10,6 +10,14 @@ import ( "github.com/tendermint/starport/starport/pkg/cmdrunner/step" ) +var ( + // ErrAccountAlreadyExists returned when an already exists account attempted to be imported. + ErrAccountAlreadyExists = errors.New("account already exists") + + // ErrAccountDoesNotExist returned when account does not exit. + ErrAccountDoesNotExist = errors.New("account does not exit") +) + // AddAccount creates a new account or imports an account when mnemonic is provided. // returns with an error if the operation went unsuccessful or an account with the provided name // already exists. @@ -26,7 +34,7 @@ func (r Runner) AddAccount(ctx context.Context, name, mnemonic string) (Account, } for _, account := range accounts { if account.Name == name { - return Account{}, errors.New("account already exists") + return Account{}, ErrAccountAlreadyExists } } b.Reset() @@ -77,9 +85,14 @@ type Account struct { // ShowAccount shows details of an account. func (r Runner) ShowAccount(ctx context.Context, name string) (Account, error) { b := &bytes.Buffer{} + if err := r.run(ctx, runOptions{stdout: b}, r.cc.ShowKeyAddressCommand(name)); err != nil { + if strings.Contains(err.Error(), "item could not be found") { + return Account{}, ErrAccountDoesNotExist + } return Account{}, err } + return Account{ Name: name, Address: strings.TrimSpace(b.String()), diff --git a/starport/pkg/chaincmd/runner/chain.go b/starport/pkg/chaincmd/runner/chain.go index fd0f232aa0..d84acab34d 100644 --- a/starport/pkg/chaincmd/runner/chain.go +++ b/starport/pkg/chaincmd/runner/chain.go @@ -3,10 +3,14 @@ package chaincmdrunner import ( "bytes" "context" + "encoding/json" + "errors" + "fmt" "regexp" "strings" "github.com/tendermint/starport/starport/pkg/chaincmd" + "github.com/tendermint/starport/starport/pkg/cosmosver" ) // Start starts the blockchain. @@ -76,3 +80,166 @@ func (r Runner) ShowNodeID(ctx context.Context) (nodeID string, err error) { nodeID = strings.TrimSpace(b.String()) return } + +// NodeStatus keeps info about node's status. +type NodeStatus struct { + ChainID string +} + +// Status returns the node's status. +func (r Runner) Status(ctx context.Context) (NodeStatus, error) { + b := &bytes.Buffer{} + + if err := r.run(ctx, runOptions{stdout: b, stderr: b}, r.cc.StatusCommand()); err != nil { + return NodeStatus{}, err + } + + var chainID string + + //nolint:gocritic // this is a false positive, json tags are actually different. + if r.cc.SDKVersion().Major().Is(cosmosver.Stargate) { + out := struct { + NodeInfo struct { + Network string `json:"network"` + } `json:"NodeInfo"` + }{} + + if err := json.NewDecoder(b).Decode(&out); err != nil { + return NodeStatus{}, err + } + + chainID = out.NodeInfo.Network + } else { + out := struct { + NodeInfo struct { + Network string `json:"network"` + } `json:"node_info"` + }{} + + if err := json.NewDecoder(b).Decode(&out); err != nil { + return NodeStatus{}, err + } + + chainID = out.NodeInfo.Network + } + + return NodeStatus{ + ChainID: chainID, + }, nil +} + +//BankSend sends amount from fromAccount to toAccount. +func (r Runner) BankSend(ctx context.Context, fromAccount, toAccount, amount string) error { + b := &bytes.Buffer{} + + if err := r.run(ctx, runOptions{stdout: b}, r.cc.BankSendCommand(fromAccount, toAccount, amount)); err != nil { + if strings.Contains(err.Error(), "key not found") || // stargate + strings.Contains(err.Error(), "unknown address") || // launchpad + strings.Contains(b.String(), "item could not be found") { // launchpad + return errors.New("account doesn't have any balances") + } + + return err + } + + out := struct { + Code int `json:"code"` + Error string `json:"raw_log"` + }{} + + if err := json.NewDecoder(b).Decode(&out); err != nil { + return err + } + + if out.Code > 0 { + return fmt.Errorf("cannot send tokens (SDK code %d): %s", out.Code, out.Error) + } + + return nil +} + +// EventSelector is used to query events. +type EventSelector struct { + typ string + attr string + value string +} + +// NewEventSelector creates a new event selector. +func NewEventSelector(typ, addr, value string) EventSelector { + return EventSelector{typ, addr, value} +} + +// Event represents a TX event. +type Event struct { + Type string + Attributes []EventAttribute +} + +// EventAttribute holds event's attributes. +type EventAttribute struct { + Key string + Value string +} + +// QueryTxEvents queries tx events by event selectors. +func (r Runner) QueryTxEvents(ctx context.Context, selector EventSelector, moreSelectors ...EventSelector) ([]Event, error) { + // prepare the slector. + var list []string + + eventsSelectors := append([]EventSelector{selector}, moreSelectors...) + + for _, event := range eventsSelectors { + list = append(list, fmt.Sprintf("%s.%s=%s", event.typ, event.attr, event.value)) + } + + query := strings.Join(list, "&") + + // execute the commnd and parse the output. + b := &bytes.Buffer{} + + if err := r.run(ctx, runOptions{stdout: b}, r.cc.QueryTxEventsCommand(query)); err != nil { + return nil, err + } + + out := struct { + Txs []struct { + Logs []struct { + Events []struct { + Type string `json:"type"` + Attrs []struct { + Key string `json:"key"` + Value string `json:"value"` + } `json:"attributes"` + } `json:"events"` + } `json:"logs"` + } `json:"txs"` + }{} + + if err := json.NewDecoder(b).Decode(&out); err != nil { + return nil, err + } + + var events []Event + + for _, tx := range out.Txs { + for _, log := range tx.Logs { + for _, e := range log.Events { + var attrs []EventAttribute + for _, attr := range e.Attrs { + attrs = append(attrs, EventAttribute{ + Key: attr.Key, + Value: attr.Value, + }) + } + + events = append(events, Event{ + Type: e.Type, + Attributes: attrs, + }) + } + } + } + + return events, nil +} diff --git a/starport/pkg/chaincmd/runner/runner.go b/starport/pkg/chaincmd/runner/runner.go index d18aaa0b95..5a4e3e215f 100644 --- a/starport/pkg/chaincmd/runner/runner.go +++ b/starport/pkg/chaincmd/runner/runner.go @@ -53,7 +53,7 @@ func Stderr(w io.Writer) Option { } // New creates a new Runner with cc and options. -func New(cc chaincmd.ChainCmd, options ...Option) Runner { +func New(ctx context.Context, cc chaincmd.ChainCmd, options ...Option) (Runner, error) { r := Runner{ cc: cc, stdout: ioutil.Discard, @@ -62,7 +62,18 @@ func New(cc chaincmd.ChainCmd, options ...Option) Runner { applyOptions(&r, options) - return r + // auto detect the chain id and get it applied to chaincmd if auto + // detection is enabled. + if cc.IsAutoChainIDDetectionEnabled() { + status, err := r.Status(ctx) + if err != nil { + return Runner{}, err + } + + r.cc = r.cc.Copy(chaincmd.WithChainID(status.ChainID)) + } + + return r, nil } func applyOptions(r *Runner, options []Option) { diff --git a/starport/pkg/cosmoscoin/coin.go b/starport/pkg/cosmoscoin/coin.go new file mode 100644 index 0000000000..e17e049632 --- /dev/null +++ b/starport/pkg/cosmoscoin/coin.go @@ -0,0 +1,40 @@ +// Package cosmoscoin provides utilities to deal with SDK coins. +package cosmoscoin + +import ( + "errors" + "fmt" + "regexp" + "strconv" +) + +var ( + errInvalidCoin = errors.New("coin is invalid") +) + +var ( + reDnmString = `[a-zA-Z][a-zA-Z0-9/]{2,127}` + reDecAmt = `[[:digit:]]+(?:\.[[:digit:]]+)?|\.[[:digit:]]+` + reSpc = `[[:space:]]*` + pattern = fmt.Sprintf(`^(%s)%s(%s)$`, reDecAmt, reSpc, reDnmString) + parseRe = regexp.MustCompile(pattern) +) + +// Parse parses a coin into amount and denom. +func Parse(c string) (amount uint64, denom string, err error) { + parsed := parseRe.FindStringSubmatch(c) + + if len(parsed) != 3 { + return 0, "", errInvalidCoin + } + + amountStr := parsed[1] + denom = parsed[2] + + amount, err = strconv.ParseUint(amountStr, 10, 64) + if err != nil { + return 0, "", errInvalidCoin + } + + return +} diff --git a/starport/pkg/cosmoscoin/coin_test.go b/starport/pkg/cosmoscoin/coin_test.go new file mode 100644 index 0000000000..1affb0315b --- /dev/null +++ b/starport/pkg/cosmoscoin/coin_test.go @@ -0,0 +1,19 @@ +package cosmoscoin + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + amount, denom, err := Parse("100token") + require.NoError(t, err) + require.Equal(t, uint64(100), amount) + require.Equal(t, "token", denom) +} + +func TestParseInvalid(t *testing.T) { + _, _, err := Parse("!100token") + require.Equal(t, errInvalidCoin, err) +} diff --git a/starport/pkg/cosmosfaucet/cosmosfaucet.go b/starport/pkg/cosmosfaucet/cosmosfaucet.go new file mode 100644 index 0000000000..f1adbb09ff --- /dev/null +++ b/starport/pkg/cosmosfaucet/cosmosfaucet.go @@ -0,0 +1,107 @@ +// Package cosmosfaucet is a faucet to request tokens for sdk accounts. +package cosmosfaucet + +import ( + "context" + "fmt" + + chaincmdrunner "github.com/tendermint/starport/starport/pkg/chaincmd/runner" +) + +const ( + // DefaultAccountName is the default account to transfer tokens from. + DefaultAccountName = "faucet" + + // DefaultDenom is the default denomination to distribute. + DefaultDenom = "uatom" + + // DefaultAmount specifies the default amount to transfer to an account + // on each request. + DefaultAmount = 10000000 + + // DefaultMaxAmount specifies the maximum amount that can be tranffered to an + // account in all times. + DefaultMaxAmount = 100000000 +) + +type Faucet struct { + // runner used to intereact with blockchain's binary to transfer tokens. + runner chaincmdrunner.Runner + + // accountName to transfer tokens from. + accountName string + + // accountMnemonic is the mnemonic of the account. + accountMnemonic string + + // coins keeps a list of coins that can be distributed by the faucet. + coins []coin + + // coinsMax is a denom-max pair. + // it holds the maximum amounts of coins that can be sent to a single account. + coinsMax map[string]uint64 +} + +type coin struct { + //amount is the amount of the coin can be distributed per request. + amount uint64 + + // denom is denomination of the coin to be distributed by the faucet. + denom string +} + +func (c coin) String() string { + return fmt.Sprintf("%d%s", c.amount, c.denom) +} + +// Option configures the faucetOptions. +type Option func(*Faucet) + +// Account provides the account information to transfer tokens from. +// when mnemonic isn't provided, account assumed to be exists in the keyring. +func Account(name, mnemonic string) Option { + return func(f *Faucet) { + f.accountName = name + f.accountMnemonic = mnemonic + } +} + +// Coin adds a new coin to coins list to distribute by the faucet. +// the first coin added to the list considered as the default coin during transfer requests. +// +// amount is the amount of the coin can be distributed per request. +// maxAmount is the maximum amount of the coin that can be sent to a single account. +// denom is denomination of the coin to be distributed by the faucet. +func Coin(amount, maxAmount uint64, denom string) Option { + return func(f *Faucet) { + f.coins = append(f.coins, coin{amount, denom}) + f.coinsMax[denom] = maxAmount + } +} + +// New creates a new faucet with ccr (to access and use blockchain's CLI) and given options. +func New(ctx context.Context, ccr chaincmdrunner.Runner, options ...Option) (Faucet, error) { + f := Faucet{ + runner: ccr, + accountName: DefaultAccountName, + coinsMax: make(map[string]uint64), + } + + for _, apply := range options { + apply(&f) + } + + if len(f.coins) == 0 { + Coin(DefaultAmount, DefaultMaxAmount, DefaultDenom)(&f) + } + + // import the account if mnemonic is provided. + if f.accountMnemonic != "" { + _, err := f.runner.AddAccount(ctx, f.accountName, f.accountMnemonic) + if err != nil && err != chaincmdrunner.ErrAccountAlreadyExists { + return Faucet{}, err + } + } + + return f, nil +} diff --git a/starport/pkg/cosmosfaucet/http.go b/starport/pkg/cosmosfaucet/http.go new file mode 100644 index 0000000000..661795fa86 --- /dev/null +++ b/starport/pkg/cosmosfaucet/http.go @@ -0,0 +1,121 @@ +package cosmosfaucet + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/rs/cors" + "github.com/tendermint/starport/starport/pkg/cosmoscoin" + "github.com/tendermint/starport/starport/pkg/xhttp" +) + +const ( + statusOK = "ok" + statusError = "error" +) + +type transferRequest struct { + // AccountAddress to request for coins. + AccountAddress string `json:"address"` + + // Coins that are requested. + // default ones used when this one isn't provided. + Coins []string `yaml:"coins"` +} + +type transferResponse struct { + Error string `json:"error,omitempty"` + Transfers []transfer `json:"transfers,omitempty"` +} + +type transfer struct { + Coin string `json:"coin"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +// ServeHTTP implements http.Handler to expose the functionality of Faucet.Transfer() via HTTP. +// request/response payloads are compatible with the previous implementation at allinbits/cosmos-faucet. +func (f Faucet) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // check method. + if r.Method != http.MethodPost { + responseError(w, http.StatusMethodNotAllowed, errors.New(http.StatusText(http.StatusMethodNotAllowed))) + return + } + + // add CORS. + cors.Default().Handler(http.HandlerFunc(f.handler)).ServeHTTP(w, r) +} + +func (f Faucet) handler(w http.ResponseWriter, r *http.Request) { + var req transferRequest + + // decode request into req. + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + responseError(w, http.StatusBadRequest, err) + return + } + + // determine coins to transfer. + coins, err := f.coinsToTransfer(req) + if err != nil { + responseError(w, http.StatusBadRequest, err) + return + } + + // send coins and create a transfers response. + var transfers []transfer + + for _, coin := range coins { + t := transfer{ + Coin: coin.String(), + Status: statusOK, + } + + if err := f.Transfer(r.Context(), req.AccountAddress, coin.amount, coin.denom); err != nil { + if err == context.Canceled { + return + } + + t.Status = statusError + t.Error = err.Error() + } + + transfers = append(transfers, t) + } + + // send the response. + responseSuccess(w, transfers) +} + +// coinsToTransfer determines tokens to transfer from transfer request. +func (f Faucet) coinsToTransfer(req transferRequest) ([]coin, error) { + if len(req.Coins) == 0 { + return f.coins, nil + } + + var coins []coin + for _, c := range req.Coins { + amount, denom, err := cosmoscoin.Parse(c) + if err != nil { + return nil, err + } + coins = append(coins, coin{amount, denom}) + } + + return coins, nil +} + +func responseSuccess(w http.ResponseWriter, transfers []transfer) { + xhttp.ResponseJSON(w, http.StatusOK, transferResponse{ + Transfers: transfers, + }) +} + +func responseError(w http.ResponseWriter, code int, err error) { + xhttp.ResponseJSON(w, code, transferResponse{ + Error: err.Error(), + }) +} diff --git a/starport/pkg/cosmosfaucet/transfer.go b/starport/pkg/cosmosfaucet/transfer.go new file mode 100644 index 0000000000..04f8568ba4 --- /dev/null +++ b/starport/pkg/cosmosfaucet/transfer.go @@ -0,0 +1,65 @@ +package cosmosfaucet + +import ( + "context" + "fmt" + "strconv" + "strings" + + chaincmdrunner "github.com/tendermint/starport/starport/pkg/chaincmd/runner" +) + +// TotalTransferredAmount returns the total transferred amount from faucet account to toAccountAddress. +func (f Faucet) TotalTransferredAmount(ctx context.Context, toAccountAddress, denom string) (amount uint64, err error) { + fromAccount, err := f.runner.ShowAccount(ctx, f.accountName) + if err != nil { + return 0, err + } + + events, err := f.runner.QueryTxEvents(ctx, + chaincmdrunner.NewEventSelector("message", "sender", fromAccount.Address), + chaincmdrunner.NewEventSelector("transfer", "recipient", toAccountAddress)) + if err != nil { + return 0, err + } + + for _, event := range events { + if event.Type == "transfer" { + for _, attr := range event.Attributes { + if attr.Key == "amount" { + if !strings.HasSuffix(attr.Value, denom) { + continue + } + + amountStr := strings.TrimRight(attr.Value, denom) + if a, err := strconv.ParseUint(amountStr, 10, 64); err == nil { + amount += a + } + } + } + } + } + + return amount, nil +} + +// Transfer transfer amount of tokens from the faucet account to toAccountAddress. +func (f Faucet) Transfer(ctx context.Context, toAccountAddress string, amount uint64, denom string) error { + amountStr := fmt.Sprintf("%d%s", amount, denom) + + totalSent, err := f.TotalTransferredAmount(ctx, toAccountAddress, denom) + if err != nil { + return err + } + + if f.coinsMax[denom] != 0 && totalSent >= f.coinsMax[denom] { + return fmt.Errorf("account has reached maximum credit allowed per account (%d)", f.coinsMax[denom]) + } + + fromAccount, err := f.runner.ShowAccount(ctx, f.accountName) + if err != nil { + return err + } + + return f.runner.BankSend(ctx, fromAccount.Address, toAccountAddress, amountStr) +} diff --git a/starport/pkg/fswatcher/fswatcher.go b/starport/pkg/fswatcher/fswatcher.go index 2b47e71b7c..8554d92c80 100644 --- a/starport/pkg/fswatcher/fswatcher.go +++ b/starport/pkg/fswatcher/fswatcher.go @@ -110,13 +110,10 @@ func (w *watcher) listen() { } } -func (w *watcher) addPaths(paths ...string) error { +func (w *watcher) addPaths(paths ...string) { for _, path := range paths { - if err := w.wt.AddRecursive(filepath.Join(w.workdir, path)); err != nil { - return err - } + w.wt.AddRecursive(filepath.Join(w.workdir, path)) } - return nil } func (w *watcher) isFileIgnored(path string) bool { diff --git a/starport/pkg/xhttp/server.go b/starport/pkg/xhttp/server.go new file mode 100644 index 0000000000..16f017b6ee --- /dev/null +++ b/starport/pkg/xhttp/server.go @@ -0,0 +1,30 @@ +package xhttp + +import ( + "context" + "errors" + "net/http" + "time" +) + +// ShutdownTimeout is the timeout for waiting all requests to complete. +const ShutdownTimeout = time.Minute + +// Serve starts s server and shutdowns it once the ctx is cancelled. +func Serve(ctx context.Context, s *http.Server) error { + go func() { + <-ctx.Done() + + shutdownCtx, cancel := context.WithTimeout(context.Background(), ShutdownTimeout) + defer cancel() + + s.Shutdown(shutdownCtx) + }() + + err := s.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { + return nil + } + + return err +} diff --git a/starport/services/chain/chain.go b/starport/services/chain/chain.go index b328cb6e80..c786be8b9e 100644 --- a/starport/services/chain/chain.go +++ b/starport/services/chain/chain.go @@ -119,7 +119,7 @@ func KeyringBackend(keyringBackend chaincmd.KeyringBackend) Option { } // New initializes a new Chain with options that its source lives at path. -func New(path string, options ...Option) (*Chain, error) { +func New(ctx context.Context, path string, options ...Option) (*Chain, error) { app, err := NewAppAt(path) if err != nil { return nil, err @@ -182,15 +182,16 @@ func New(path string, options ...Option) (*Chain, error) { ) } + config, err := c.Config() + if err != nil { + return nil, err + } + // use keyring backend if specified if c.options.keyringBackend != chaincmd.KeyringBackendUnspecified { ccoptions = append(ccoptions, chaincmd.WithKeyringBackend(c.options.keyringBackend)) } else { // check if keyring backend is specified in config - config, err := c.Config() - if err != nil { - return nil, err - } if config.Init.KeyringBackend != "" { configKeyringBackend, err := chaincmd.KeyringBackendFromString(config.Init.KeyringBackend) if err != nil { @@ -214,7 +215,10 @@ func New(path string, options ...Option) (*Chain, error) { chaincmdrunner.CLILogPrefix(c.genPrefix(logAppcli)), ) } - c.cmd = chaincmdrunner.New(cc, ccroptions...) + c.cmd, err = chaincmdrunner.New(ctx, cc, ccroptions...) + if err != nil { + return nil, err + } return c, nil } diff --git a/starport/services/chain/conf/config.go b/starport/services/chain/conf/config.go index 7d31f5679b..9f83bad93b 100644 --- a/starport/services/chain/conf/config.go +++ b/starport/services/chain/conf/config.go @@ -23,11 +23,14 @@ var ( Servers: Servers{ RPCAddr: "0.0.0.0:26657", P2PAddr: "0.0.0.0:26656", - ProfAddr: "localhost:6060", + ProfAddr: "0.0.0.0:6060", GRPCAddr: "0.0.0.0:9090", APIAddr: "0.0.0.0:1317", - FrontendAddr: "localhost:8080", - DevUIAddr: "localhost:12345", + FrontendAddr: "0.0.0.0:8080", + DevUIAddr: "0.0.0.0:12345", + }, + Faucet: Faucet{ + Port: 4500, }, } ) @@ -37,6 +40,7 @@ var ( type Config struct { Accounts []Account `yaml:"accounts"` Validator Validator `yaml:"validator"` + Faucet Faucet `yaml:"faucet"` Init Init `yaml:"init"` Genesis map[string]interface{} `yaml:"genesis"` Servers Servers `yaml:"servers"` @@ -68,6 +72,22 @@ type Validator struct { Staked string `yaml:"staked"` } +// Faucet configuration. +type Faucet struct { + // Port number for faucet server to listen at. + Port int `yaml:"port"` + + // Name is faucet account's name. + Name *string `yaml:"name"` + + // Coins holds type of coin denoms and amounts to distribute. + Coins []string `yaml:"coins"` + + // CoinsMax holds of chain denoms and their max amounts that can be transferred + // to single user. + CoinsMax []string `yaml:"coins_max"` +} + // Init overwrites sdk configurations with given values. type Init struct { // App overwrites appd's config/app.toml configs. diff --git a/starport/services/chain/serve.go b/starport/services/chain/serve.go index ccbb41436d..425edb1f2a 100644 --- a/starport/services/chain/serve.go +++ b/starport/services/chain/serve.go @@ -12,13 +12,16 @@ import ( "path" "path/filepath" "strings" - "time" "github.com/pkg/errors" + chaincmdrunner "github.com/tendermint/starport/starport/pkg/chaincmd/runner" "github.com/tendermint/starport/starport/pkg/cmdrunner" "github.com/tendermint/starport/starport/pkg/cmdrunner/step" + "github.com/tendermint/starport/starport/pkg/cosmoscoin" + "github.com/tendermint/starport/starport/pkg/cosmosfaucet" "github.com/tendermint/starport/starport/pkg/fswatcher" "github.com/tendermint/starport/starport/pkg/xexec" + "github.com/tendermint/starport/starport/pkg/xhttp" "github.com/tendermint/starport/starport/pkg/xos" "github.com/tendermint/starport/starport/pkg/xurl" "github.com/tendermint/starport/starport/services/chain/conf" @@ -59,6 +62,7 @@ func (c *Chain) Serve(ctx context.Context) error { g.Go(func() error { return c.runDevServer(ctx) }) + g.Go(func() error { c.refreshServe() for { @@ -228,18 +232,47 @@ func (c *Chain) serve(ctx context.Context) error { func (c *Chain) start(ctx context.Context, conf conf.Config) error { g, ctx := errgroup.WithContext(ctx) + // start the blockchain. g.Go(func() error { return c.plugin.Start(ctx, c.Commands(), conf) }) - fmt.Fprintf(c.stdLog(logStarport).out, "šŸŒ Running a Cosmos '%[1]v' app with Tendermint at %s.\n", c.app.Name, xurl.HTTP(conf.Servers.RPCAddr)) - fmt.Fprintf(c.stdLog(logStarport).out, "šŸŒ Running a server at %s (LCD)\n", xurl.HTTP(conf.Servers.APIAddr)) - fmt.Fprintf(c.stdLog(logStarport).out, "\nšŸš€ Get started: %s\n\n", xurl.HTTP(conf.Servers.DevUIAddr)) - + // run relayer. go func() { if err := c.initRelayer(ctx, conf); err != nil && ctx.Err() == nil { fmt.Fprintf(c.stdLog(logStarport).err, "could not init relayer: %s", err) } }() + // start the faucet if enabled. + var isFaucedEnabled bool + + if conf.Faucet.Name != nil { + if _, err := c.cmd.ShowAccount(ctx, *conf.Faucet.Name); err != nil { + if err == chaincmdrunner.ErrAccountDoesNotExist { + return &CannotBuildAppError{errors.Wrap(err, "faucet account doesn't exist")} + } + return err + } + + isFaucedEnabled = true + + g.Go(func() (err error) { + if err := c.runFaucetServer(ctx); err != nil { + return &CannotBuildAppError{err} + } + return nil + }) + } + + // print the server addresses. + fmt.Fprintf(c.stdLog(logStarport).out, "šŸŒ Running a Cosmos '%[1]v' app with Tendermint at %s.\n", c.app.Name, xurl.HTTP(conf.Servers.RPCAddr)) + fmt.Fprintf(c.stdLog(logStarport).out, "šŸŒ Running a server at %s (LCD)\n", xurl.HTTP(conf.Servers.APIAddr)) + + if isFaucedEnabled { + fmt.Fprintf(c.stdLog(logStarport).out, "šŸŒ Running a faucet at http://localhost:%d\n", conf.Faucet.Port) + } + + fmt.Fprintf(c.stdLog(logStarport).out, "\nšŸš€ Get started: %s\n\n", xurl.HTTP(conf.Servers.DevUIAddr)) + return g.Wait() } @@ -311,23 +344,56 @@ func (c *Chain) runDevServer(ctx context.Context) error { if err != nil { return err } - sv := &http.Server{ + + return xhttp.Serve(ctx, &http.Server{ Addr: config.Servers.DevUIAddr, Handler: handler, + }) +} + +func (c *Chain) runFaucetServer(ctx context.Context) error { + config, err := c.Config() + if err != nil { + return err } - go func() { - <-ctx.Done() - shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second*5) - defer cancel() - sv.Shutdown(shutdownCtx) - }() + faucetOptions := []cosmosfaucet.Option{ + cosmosfaucet.Account(*config.Faucet.Name, ""), + } - err = sv.ListenAndServe() - if errors.Is(err, http.ErrServerClosed) { - return nil + // parse coins to pass to the faucet as coins. + for _, coin := range config.Faucet.Coins { + amount, denom, err := cosmoscoin.Parse(coin) + if err != nil { + return fmt.Errorf("%s: %s", err, coin) + } + + var amountMax uint64 + + // find out the max amount for this coin. + for _, coinMax := range config.Faucet.CoinsMax { + amount, denomMax, err := cosmoscoin.Parse(coinMax) + if err != nil { + return fmt.Errorf("%s: %s", err, coin) + } + if denomMax == denom { + amountMax = amount + break + } + } + + faucetOptions = append(faucetOptions, cosmosfaucet.Coin(amount, amountMax, denom)) } - return err + + faucet, err := cosmosfaucet.New(ctx, c.cmd, faucetOptions...) + if err != nil { + return err + } + + return xhttp.Serve(ctx, &http.Server{ + Addr: fmt.Sprintf("0.0.0.0:%d", config.Faucet.Port), + Handler: faucet, + }) } type CannotBuildAppError struct { diff --git a/starport/services/networkbuilder/blockchain.go b/starport/services/networkbuilder/blockchain.go index 6728f64aa6..7dd44a145d 100644 --- a/starport/services/networkbuilder/blockchain.go +++ b/starport/services/networkbuilder/blockchain.go @@ -77,7 +77,7 @@ func (b *Blockchain) init( chainOption = append(chainOption, chain.KeyringBackend(chaincmd.KeyringBackendTest)) } - c, err := chain.New(b.appPath, chainOption...) + c, err := chain.New(ctx, b.appPath, chainOption...) if err != nil { return err } diff --git a/starport/services/networkbuilder/networkbuilder.go b/starport/services/networkbuilder/networkbuilder.go index 5004165405..e59df4d608 100644 --- a/starport/services/networkbuilder/networkbuilder.go +++ b/starport/services/networkbuilder/networkbuilder.go @@ -350,7 +350,7 @@ func (b *Builder) StartChain(ctx context.Context, chainID string, flags []string } appPath := filepath.Join(sourcePath, chainID) - chainHandler, err := chain.New(appPath, chainOption...) + chainHandler, err := chain.New(ctx, appPath, chainOption...) if err != nil { return err } diff --git a/starport/services/networkbuilder/simulation.go b/starport/services/networkbuilder/simulation.go index 27da03485a..ea7ab560d5 100644 --- a/starport/services/networkbuilder/simulation.go +++ b/starport/services/networkbuilder/simulation.go @@ -48,7 +48,7 @@ func (b *Builder) VerifyProposals(ctx context.Context, chainID string, homeDir s defer os.RemoveAll(tmpHome) appPath := filepath.Join(sourcePath, chainID) - chainHandler, err := chain.New(appPath, + chainHandler, err := chain.New(ctx, appPath, chain.HomePath(tmpHome), chain.LogLevel(chain.LogSilent), chain.KeyringBackend(chaincmd.KeyringBackendTest),