diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d20919c1..8523edb7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,17 +8,31 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - ### Fixed +### Changed + +### Removed + +## [0.76.0] - 2021-03-09 + +### Added + +* Added functionality to locate cx spec via a local file or `cx-tracker` based on a command line flag (https://github.com/skycoin/cx-chains/pull/31/commits/8b3369d981f6b5b6cc7a8007484c1a947c264ba7). +* Added force client node flag for `cxchain` (https://github.com/skycoin/cx-chains/pull/31/commits/0806031ddc3fd9fb4eebb432ee1cf6bcfd096dfa). +* Implemented `cxchain-cli post` subcommand (https://github.com/skycoin/cx-chains/pull/31/commits/cd615f99680b6a692eab914d729aa309ddce9810). +* Implemented `cxchain-cli key` subcommand (https://github.com/skycoin/cx-chains/pull/31/commits/3025f6be7db135a3cf526af51c7901528912eb5f, https://github.com/skycoin/cx-chains/pull/31/commits/ebe2d204516b45ac875c234dff694639e98ebb32) +* Implemented `cxchain-cli genesis` subcommand (https://github.com/skycoin/cx-chains/pull/31/commits/ebe2d204516b45ac875c234dff694639e98ebb32). +* Initial work on integration tests (https://github.com/skycoin/cx-chains/pull/31/commits/8b533cb1e6de6bf47047d4f6f2f216cf21fcc9ea, https://github.com/skycoin/cx-chains/pull/31/commits/4dbb0cac096630c9025c0ca1ebc125980624b4ab). + +### Fixed ### Changed -Complete refactor. Preparing for CX Version 1.0 milestone +* General commandline interface changes for `cxchain` and `cxchain-cli` (https://github.com/skycoin/cx-chains/pull/31/commits/13761d78ea86499d8f3005cc114eca985c68cc1c). ### Removed - ## [0.75.0] - 2020-12-01 ### Added @@ -27,8 +41,6 @@ Preparing for Version 1.0 milestone. Complete refactor. Massive code changes. ### Fixed - ### Changed - ### Removed diff --git a/README.md b/README.md index f97f12b0f..80aa00fd2 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,9 @@ You will need to specify an address of a `cx-tracker` for a `cxchain` instance t *This local environment has two `cxchain` instances and a `cx-tracker`.* -Start `cx-tracker` with default setting. +Start `cx-tracker`. ```bash -$ cx-tracker +$ cx-tracker -addr ":9091" ``` Generate new chain spec (assuming that the repository root is your working directory). @@ -45,26 +45,40 @@ Generate new chain spec (assuming that the repository root is your working direc $ cxchain-cli new ./cx/examples/counter-bc.cx ``` +Post chain spec to `cx-tracker`. +```bash +$ export CXCHAIN_SK=$(cxchain-cli key -in skycoin.chain_keys.json -field "seckey") +$ cxchain-cli post -t "http://127.0.0.1:9091" -s skycoin.chain_spec.json +``` + +At this point, you can head to [http://127.0.0.1:9091/api/specs](http://127.0.0.1:9091/api/specs) to see whether the spec is posted to `cx-tracker`. + Run publisher node with generated chain spec. * Obtain the chain secret key from generated `{coin}.chain_keys.json` file. ```bash -$ CXCHAIN_SK=publisher_secret_key cxchain -enable-all-api-sets +$ export CXCHAIN_SK=$(cxchain-cli key -in skycoin.chain_keys.json -field "seckey") +$ export CXCHAIN_HASH=$(cxchain-cli genesis -in skycoin.chain_spec.json) +$ cxchain -chain "tracker:$CXCHAIN_HASH" -tracker "http://127.0.0.1:9091" -enable-all-api-sets -data-dir ./master_node -port 6001 -web-interface-port 6421 ``` Run client node with generated chain spec (use different data dir, and ports to publisher node). * As no `CXCHAIN_SK` is provided, a random key pair is generated for the node. ```bash -$ cxchain -enable-all-api-sets -data-dir "$HOME/.cxchain/skycoin_client" -port 6002 -web-interface-port 6422 +$ export CXCHAIN_HASH=$(cxchain-cli genesis -in skycoin.chain_spec.json) +$ cxchain -chain "tracker:$CXCHAIN_HASH" -tracker "http://127.0.0.1:9091" -client -enable-all-api-sets -data-dir ./client_node -port 6002 -web-interface-port 6422 ``` Run transaction against publisher node. ```bash -$ cxchain-cli run ./cx/examples/counter-tx.cx +$ export CXCHAIN_HASH=$(cxchain-cli genesis -in skycoin.chain_spec.json) +$ cxchain-cli run -chain "tracker:$CXCHAIN_HASH" -tracker "http://127.0.0.1:9091" ./cx/examples/counter-tx.cx ``` Run transaction against client node and inject. ```bash -$ CXCHAIN_GEN_SK=genesis_secret_key cxchain-cli run -n "http://127.0.0.1:6422" -i ./cx/examples/counter-tx.cx +$ export CXCHAIN_GEN_SK=$(cxchain-cli key -in skycoin.genesis_keys.json -field "seckey") +$ export CXCHAIN_HASH=$(cxchain-cli genesis -in skycoin.chain_spec.json) +$ cxchain-cli run -chain "tracker:$CXCHAIN_HASH" -tracker "http://127.0.0.1:9091" -node "http://127.0.0.1:6422" -inject ./cx/examples/counter-tx.cx ``` ## Resources diff --git a/cmd/cxchain-cli/cmd_genesis.go b/cmd/cxchain-cli/cmd_genesis.go new file mode 100644 index 000000000..93e96e0f7 --- /dev/null +++ b/cmd/cxchain-cli/cmd_genesis.go @@ -0,0 +1,69 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + + "github.com/skycoin/cx-chains/src/cx/cxspec" + "github.com/skycoin/cx-chains/src/cx/cxutil" +) + +type genesisFlags struct { + cmd *flag.FlagSet + + in string +} + +func processGenesisFlags(args []string) genesisFlags { + // Specify default flag values. + f := genesisFlags{ + cmd: flag.NewFlagSet("cxchain-cli genesis", flag.ExitOnError), + in: "skycoin.chain_spec.json", + } + + f.cmd.Usage = func() { + usage := cxutil.DefaultUsageFormat("flags") + usage(f.cmd, nil) + } + + f.cmd.StringVar(&f.in, "in", f.in, "`FILENAME` of file to read in") + + if err := f.cmd.Parse(args); err != nil { + os.Exit(1) + } + + return f +} + +func cmdGenesis(args []string) { + flags := processGenesisFlags(args) + + f, err := os.Open(flags.in) + if err != nil { + log.WithError(err). + Fatal("Failed to read in file.") + } + defer func() { + if err := f.Close(); err != nil { + log.WithError(err). + Fatal("Failed to close file.") + } + }() + + var cSpec cxspec.ChainSpec + if err := json.NewDecoder(f).Decode(&cSpec); err != nil { + log.WithError(err). + Fatal("Failed to decode file") + } + + block, err := cSpec.GenerateGenesisBlock() + if err != nil { + log.WithError(err). + Fatal("Failed to generate genesis block.") + } + + hash := block.HashHeader() + fmt.Println(hash.Hex()) +} \ No newline at end of file diff --git a/cmd/cxchain-cli/cmd_key.go b/cmd/cxchain-cli/cmd_key.go new file mode 100644 index 000000000..6e99221b3 --- /dev/null +++ b/cmd/cxchain-cli/cmd_key.go @@ -0,0 +1,83 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + + "github.com/skycoin/cx-chains/src/cx/cxspec" + "github.com/skycoin/cx-chains/src/cx/cxutil" +) + +type keyFlags struct { + cmd *flag.FlagSet + + in string + field string +} + +func processKeyFlags(args []string) keyFlags { + // Specify default flag values. + f := keyFlags{ + cmd: flag.NewFlagSet("cxchain-cli key", flag.ExitOnError), + in: "skycoin.chain_keys.json", // TODO @evanlinjin: Find const for this value. + field: "seckey", + } + + f.cmd.Usage = func() { + usage := cxutil.DefaultUsageFormat("flags") + usage(f.cmd, nil) + } + + f.cmd.StringVar(&f.in, "in", f.in, "`FILENAME` of file to read in") + f.cmd.StringVar(&f.field, "field", f.field, "`NAME` of field to print") + + if err := f.cmd.Parse(args); err != nil { + os.Exit(1) + } + + return f +} + +func cmdKey(args []string) { + flags := processKeyFlags(args) + + f, err := os.Open(flags.in) + if err != nil { + log.WithError(err). + Fatal("Failed to read in file.") + } + defer func() { + if err := f.Close(); err != nil { + log.WithError(err). + Fatal("Failed to close file.") + } + }() + + var kSpec cxspec.KeySpec + if err := json.NewDecoder(f).Decode(&kSpec); err != nil { + log.WithError(err). + Fatal("Failed to decode file.") + } + + var out string + + switch flags.field { + case "spec_era": + out = kSpec.SpecEra + case "key_type": + out = kSpec.KeyType + case "pubkey": + out = kSpec.PubKey + case "seckey": + out = kSpec.SecKey + case "address": + out = kSpec.Address + default: + log.WithField("field", flags.field). + Fatal("Invalid field input.") + } + + fmt.Println(out) +} diff --git a/cmd/cxchain-cli/cmd_peers.go b/cmd/cxchain-cli/cmd_peers.go index 17dfc954f..64413557d 100644 --- a/cmd/cxchain-cli/cmd_peers.go +++ b/cmd/cxchain-cli/cmd_peers.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "flag" "fmt" @@ -13,14 +14,9 @@ import ( ) func cmdPeers(args []string) { - // spec is the chain spec obtained from ENV - if err := globals.specErr; err != nil { - log.WithError(err).Fatal() - } - spec := globals.spec - // rootCmd is the root command of the 'peers' subcommand rootCmd := flag.NewFlagSet("cxchain-cli peers", flag.ExitOnError) + spec := processSpecFlags(context.Background(), rootCmd, args) // nodeAddr holds the value parsed from the flags 'node' and 'n' nodeAddr := fmt.Sprintf("http://127.0.0.1:%d", spec.Node.WebInterfacePort) diff --git a/cmd/cxchain-cli/cmd_post.go b/cmd/cxchain-cli/cmd_post.go new file mode 100644 index 000000000..b238168ba --- /dev/null +++ b/cmd/cxchain-cli/cmd_post.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "flag" + "github.com/skycoin/cx-chains/src/cx/cxspec" + "github.com/skycoin/cx-chains/src/cx/cxutil" + "github.com/skycoin/skycoin/src/cipher" + "os" +) + +type postFlags struct { + cmd *flag.FlagSet + + specInput string // chain spec input filename + signedOutput string // signed chain spec output filename + dryRun bool // if set, spec file will not be posted to cx-tracker + tracker string // cx tracker URL +} + +func processPostFlags(args []string) (postFlags, cipher.SecKey) { + // Specify default flag values. + f := postFlags{ + cmd: flag.NewFlagSet("cxchain-cli post", flag.ExitOnError), + + specInput: cxspec.DefaultSpecFilepath, + signedOutput: "", // empty for no output + dryRun: false, + tracker: cxspec.DefaultTrackerURL, + } + + f.cmd.Usage = func() { + usage := cxutil.DefaultUsageFormat("flags") + usage(f.cmd, nil) + printChainSKENV(f.cmd) + } + + f.cmd.StringVar(&f.specInput, "spec", f.specInput, "`FILENAME` of chain spec file input") + f.cmd.StringVar(&f.specInput, "s", f.specInput, "shorthand for 'spec'") + + f.cmd.StringVar(&f.signedOutput, "output", f.signedOutput, "`FILENAME` for signed chain spec output (empty for no output)") + f.cmd.StringVar(&f.signedOutput, "o", f.signedOutput, "shorthand for 'output'") + + f.cmd.BoolVar(&f.dryRun, "dry", f.dryRun, "whether to do a dry run (no actual post to cx-tracker)") + + f.cmd.StringVar(&f.tracker, "tracker", f.tracker, "`URL` for cx-tracker") + f.cmd.StringVar(&f.tracker, "t", f.tracker, "shorthand for 'tracker'") + + // Parse flags. + if err := f.cmd.Parse(args); err != nil { + os.Exit(1) + } + + // Parse ENVs. + genSK, err := parseSKEnv(chainSKEnv) + if err != nil { + log.WithError(err). + WithField(chainSKEnv, genSK.Hex()). + Fatal("Failed to read secret key from ENV.") + } + + return f, genSK +} + +func cmdPost(args []string) { + flags, genSK := processPostFlags(args) + + // Obtain chain spec. + spec, err := cxspec.ReadSpecFile(flags.specInput) + if err != nil { + log.WithError(err). + Fatal("Failed to read spec file.") + } + + // Sign chain spec. + signed, err := cxspec.MakeSignedChainSpec(spec, genSK) + if err != nil { + log.WithError(err). + Fatal("Failed to make signed chain spec.") + } + + if flags.signedOutput != "" { + // TODO @evanlinjin: Write signed output. + panic("flag 'output,o' is not implemented yet") + } + + // tracker client. + tC := cxspec.NewCXTrackerClient(log, nil, flags.tracker) + + if !flags.dryRun { + if err := tC.PostSpec(context.Background(), signed); err != nil { + log.WithError(err).Fatal("Failed to post spec to cx-tracker.") + } + } else { + log.WithField("dry_run", flags.dryRun). + Info("This is a dry run.") + } + + log.WithField("spec_file", flags.specInput). + WithField("cx_tracker", flags.tracker). + Info("Chain spec file successfully posted!") +} diff --git a/cmd/cxchain-cli/cmd_run.go b/cmd/cxchain-cli/cmd_run.go index 5486486c1..f3d50e636 100644 --- a/cmd/cxchain-cli/cmd_run.go +++ b/cmd/cxchain-cli/cmd_run.go @@ -1,6 +1,7 @@ package main import ( + "context" "flag" "fmt" "os" @@ -28,42 +29,65 @@ type runFlags struct { nodeAddr string // CX Chain node address. } -func processRunFlags(args []string) (runFlags, cxspec.ChainSpec, cipher.SecKey) { - if err := globals.specErr; err != nil { - log.WithError(err).Fatal() +// printGenSKENV prints the ENV help menu containing 'genSKEnv'. +func printGenSKENV(cmd *flag.FlagSet) { + cxutil.CmdPrintf(cmd, "ENVs:") + cxutil.CmdPrintf(cmd, " $%s\n \t%s", genSKEnv, "genesis secret key (hex), required if '-inject' flag is set") +} + +// printChainSKENV prints the ENV help menu containing 'chainSKEnv'. +func printChainSKENV(cmd *flag.FlagSet) { + cxutil.CmdPrintf(cmd, "ENVs:") + cxutil.CmdPrintf(cmd, " $%s\n \t%s", chainSKEnv, "chain secret key (hex) for registering chain and publishing blocks") +} + +// readRunENVs parses ENVs specified for the 'run' subcommand +func readRunENVs(specAddr cipher.Address) cipher.SecKey { + genSK, err := parseSKEnv(genSKEnv) + if err != nil { + log.WithError(err). + WithField(genSKEnv, genSK.Hex()). + Fatal("Failed to read secret key from ENV.") } - spec := globals.spec - genSK := globals.genSK - - // Check genesis secret key. - if !genSK.Null() { - genAddr, err := cipher.AddressFromSecKey(genSK) - if err != nil { - log.WithError(err). - WithField(genSKEnv, genSK.Hex()). - Fatal("Failed to extract genesis address.") - } - if specAddr := cipher.MustDecodeBase58Address(spec.GenesisAddr); genAddr != specAddr { - log.WithField(genSKEnv, genSK.Hex()). - Fatal("Provided genesis secret key does not match genesis address from chain spec.") - } + genAddr, err := cipher.AddressFromSecKey(genSK) + if err != nil { + log.WithError(err). + WithField(genSKEnv, genSK.Hex()). + Fatal("Failed to extract genesis address.") + } + + if genAddr != specAddr { + log.WithField(genSKEnv, genSK.Hex()). + Fatal("Provided genesis secret key does not match genesis address from chain spec.") } + return genSK +} + +func processRunFlags(args []string) (runFlags, cxspec.ChainSpec, cipher.SecKey) { + cmd := flag.NewFlagSet("cxchain-cli run", flag.ExitOnError) + spec := processSpecFlags(context.Background(), cmd, args) + f := runFlags{ - cmd: flag.NewFlagSet("cxchain-cli run", flag.ExitOnError), + cmd: cmd, debugLexer: false, debugProfile: 0, MemoryFlags: cxflags.DefaultMemoryFlags(), - inject: false, + inject: false, + + // TODO @evanlinjin: Find a way to set this later on. + // TODO @evanlinjin: This way, we would not need to call '.Locate' so + // TODO @evanlinjin: early within processSpecFlags() nodeAddr: fmt.Sprintf("http://127.0.0.1:%d", spec.Node.WebInterfacePort), } f.cmd.Usage = func() { usage := cxutil.DefaultUsageFormat("flags", "cx source files") usage(f.cmd, nil) + printGenSKENV(f.cmd) } f.cmd.BoolVar(&f.debugLexer, "debug-lexer", f.debugLexer, "enable lexer debugging by printing all scanner tokens") @@ -81,11 +105,10 @@ func processRunFlags(args []string) (runFlags, cxspec.ChainSpec, cipher.SecKey) os.Exit(1) } - // Check stuff. - if f.inject && globals.genSKErr != nil { - log.WithError(globals.genSKErr). - WithField("ENV", genSKEnv). - Fatal("Genesis secret key should be provided to inject transaction.") + // Ensure genesis secret key is provided if 'inject' flag is set. + var genSK cipher.SecKey + if f.inject { + genSK = readRunENVs(cipher.MustDecodeBase58Address(spec.GenesisAddr)) } // Log stuff. diff --git a/cmd/cxchain-cli/cmd_state.go b/cmd/cxchain-cli/cmd_state.go index ec1686d4a..e090c8e54 100644 --- a/cmd/cxchain-cli/cmd_state.go +++ b/cmd/cxchain-cli/cmd_state.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/hex" "flag" "fmt" @@ -25,13 +26,11 @@ type stateFlags struct { } func processStateFlags(args []string) (stateFlags, cipher.Address) { - if err := globals.specErr; err != nil { - log.WithError(err).Fatal() - } - spec := globals.spec + cmd := flag.NewFlagSet("cxchain-cli state", flag.ExitOnError) + spec := processSpecFlags(context.Background(), cmd, args) f := stateFlags{ - cmd: flag.NewFlagSet("cxchain-cli state", flag.ExitOnError), + cmd: cmd, MemoryFlags: cxflags.DefaultMemoryFlags(), nodeAddr: fmt.Sprintf("http://127.0.0.1:%d", spec.Node.WebInterfacePort), appAddr: cipher.MustDecodeBase58Address(spec.GenesisAddr).String(), diff --git a/cmd/cxchain-cli/globals.go b/cmd/cxchain-cli/globals.go index 79ae8512c..2bd5cfb67 100644 --- a/cmd/cxchain-cli/globals.go +++ b/cmd/cxchain-cli/globals.go @@ -1,7 +1,9 @@ package main import ( + "context" "errors" + "flag" "fmt" "os" @@ -11,70 +13,41 @@ import ( ) const ( - // ENV for chain spec filepath. - specFileEnv = "CXCHAIN_SPEC" - defaultChainSpecFile = "./skycoin.chain_spec.json" - - // ENV for genesis secret key. + // genSKEnv is the ENV for genesis secret key. genSKEnv = "CXCHAIN_GEN_SK" + + // chainSKEnv is the ENV for chain secret key. + chainSKEnv = "CXCHAIN_SK" ) var ErrNoSKProvided = errors.New("no secret key provided") -var globals = struct { - spec cxspec.ChainSpec // chain spec struct obtained from file defined in `CXCHAIN_SPEC_FILE` ENV - specFilename string // chain spec filename - specErr error // error when obtaining chain spec - - genSK cipher.SecKey // genesis secret key obtained from SK defined in `CXCHAIN_GENESIS_SK` ENV - genSKErr error // error when obtaining genesis secret key -}{} - -// initGlobals initiates globals. This is called in main. -func initGlobals() { - globals.spec, globals.specFilename, globals.specErr = parseSpecFileEnv() - globals.genSK, globals.genSKErr = parseGenesisSKEnv() - - log := log. - WithField(specFileEnv, nil). - WithField(genSKEnv, nil) - - if globals.specErr == nil { - log = log.WithField(specFileEnv, globals.specFilename) - } - if globals.genSKErr == nil { - log = log.WithField(genSKEnv, globals.genSK.Hex()) - } - - log.Info("Environment variables:") -} - -// parseSpecFileEnv parses chain spec filename from CXCHAIN_SPEC_FILEPATH env. -func parseSpecFileEnv() (cxspec.ChainSpec, string, error) { - specFilename, ok := os.LookupEnv(specFileEnv) - if !ok { - specFilename = defaultChainSpecFile - } - - spec, err := cxspec.ReadSpecFile(specFilename) - if err != nil { - return cxspec.ChainSpec{}, specFilename, fmt.Errorf("failed to read chain spec from %s: %w", specFilename, err) - } - - // log.WithField("filename", specFilename).Info("Using chain spec.") - cxspec.PopulateParamsModule(spec) - return spec, specFilename, nil -} - -// parseGenesisSKEnv parses secret key from CXCHAIN_SECRET_KEY env. +// parseSKEnv parses secret key from CXCHAIN_SECRET_KEY env. // The secret key can be null. -func parseGenesisSKEnv() (cipher.SecKey, error) { - if skStr, ok := os.LookupEnv(genSKEnv); ok { +func parseSKEnv(envKey string) (cipher.SecKey, error) { + if skStr, ok := os.LookupEnv(envKey); ok { sk, err := cipher.SecKeyFromHex(skStr) if err != nil { - return cipher.SecKey{}, fmt.Errorf("failed to parse secret key defined in ENV '%s': %w", genSKEnv, err) + return cipher.SecKey{}, fmt.Errorf("failed to parse secret key defined in ENV '%s': %w", envKey, err) } return sk, nil } return cipher.SecKey{}, ErrNoSKProvided } + +// processSpecFlags parses the 'chain' flag which defines where to discover the +// chain spec file. +func processSpecFlags(ctx context.Context, cmd *flag.FlagSet, args []string) cxspec.ChainSpec { + conf := cxspec.DefaultLocateConfig() + conf.RegisterFlags(cmd) + conf.SoftParse(args) + + spec, err := cxspec.LocateWithConfig(ctx, &conf) + if err != nil { + log.WithError(err). + WithField("chain", conf.CXChain). + Fatal("Failed to find cx spec.") + } + + return spec +} diff --git a/cmd/cxchain-cli/main.go b/cmd/cxchain-cli/main.go index f85cd10da..1bb79b07c 100644 --- a/cmd/cxchain-cli/main.go +++ b/cmd/cxchain-cli/main.go @@ -12,15 +12,13 @@ import ( var log = logging.MustGetLogger("cxchain-cli") func main() { - initGlobals() - usageMenu := cxutil.UsageFormat(func(cmd *flag.FlagSet, subcommands []string) { // print: Usage cxutil.PrintCmdUsage(cmd, "Usage", subcommands, []string{"args"}) // print: ENVs cxutil.CmdPrintf(cmd, "ENVs:") - cxutil.CmdPrintf(cmd, " $%s\n \t%s", specFileEnv, "chain spec filepath") + cxutil.CmdPrintf(cmd, " $%s\n \t%s", chainSKEnv, "chain secret key (hex)") cxutil.CmdPrintf(cmd, " $%s\n \t%s", genSKEnv, "genesis secret key (hex)") // print: Flags @@ -28,13 +26,16 @@ func main() { }) root := cxutil.NewCommandMap(flag.CommandLine, 7, usageMenu). - AddSubcommand("version", func(_ []string) { cmdVersion() }). - AddSubcommand("help", func(_ []string) { flag.CommandLine.Usage() }). + AddSubcommand("version", func([]string) { cmdVersion() }). + AddSubcommand("help", func([]string) { flag.CommandLine.Usage() }). AddSubcommand("tokenize", cmdTokenize). AddSubcommand("new", cmdNew). + AddSubcommand("post", cmdPost). AddSubcommand("run", cmdRun). AddSubcommand("state", cmdState). - AddSubcommand("peers", cmdPeers) + AddSubcommand("peers", cmdPeers). + AddSubcommand("key", cmdKey). + AddSubcommand("genesis", cmdGenesis) os.Exit(root.ParseAndRun(os.Args[1:])) } diff --git a/cmd/cxchain/cxchain.go b/cmd/cxchain/cxchain.go index 429984c1d..284ef7b76 100644 --- a/cmd/cxchain/cxchain.go +++ b/cmd/cxchain/cxchain.go @@ -22,10 +22,6 @@ import ( ) const ( - // ENV for chain spec filepath. - specFileEnv = "CXCHAIN_SPEC_FILEPATH" - defaultChainSpecFile = "./skycoin.chain_spec.json" - // ENV for the chain secret key (in hex). secKeyEnv = "CXCHAIN_SK" @@ -33,7 +29,7 @@ const ( standaloneClientConfMode = "STANDALONE_CLIENT" ) -// These values should be populated by -ldflags on compilation +// These values should be populated by -ldflags on compilation. var ( version = "0.0.0" commit = "" @@ -41,24 +37,32 @@ var ( confMode = "" // valid values: "STANDALONE_CLIENT", "" ) -// Logger. +// log contains the main logger. var log = logging.MustGetLogger("main") -// parseSpecFilepathEnv parses chain spec filename from CXCHAIN_SPEC_FILEPATH env. -func parseSpecFilepathEnv() cxspec.ChainSpec { - specFilename, ok := os.LookupEnv(specFileEnv) - if !ok { - specFilename = defaultChainSpecFile - } +// Additional flags. +var ( + dmsgDiscAddr = cxdmsg.DefaultDiscAddr // dmsg discovery address + dmsgPort = uint64(cxdmsg.DefaultPort) // dmsg listening port + forceClient = false // more client mode (as opposed to publisher) + + // specFlags contains default cx spec discovery flags + specFlags = cxspec.DefaultLocateConfig() +) - log.WithField("filename", specFilename).Info("Reading chain spec file...") +// locateSpec locates the spec location either from a local spec file or from a +// CX tracker instance. +func locateSpec() cxspec.ChainSpec { + specFlags.RegisterFlags(flag.CommandLine) + specFlags.SoftParse(os.Args) - spec, err := cxspec.ReadSpecFile(specFilename) + spec, err := cxspec.LocateWithConfig(context.Background(), &specFlags) if err != nil { - log.WithError(err).Fatal("Failed to start node.") + log.WithError(err). + WithField("chain", specFlags.CXChain). + Fatal("Failed to find cx spec.") } - cxspec.PopulateParamsModule(spec) return spec } @@ -68,7 +72,9 @@ func parseSecretKeyEnv() cipher.SecKey { if skStr, ok := os.LookupEnv(secKeyEnv); ok { sk, err := cipher.SecKeyFromHex(skStr) if err != nil { - log.WithError(err).WithField("ENV", secKeyEnv).Fatal("Provided secret key is invalid.") + log.WithError(err). + WithField("ENV", secKeyEnv). + Fatal("Provided secret key is invalid.") } return sk } @@ -87,23 +93,11 @@ func ensureConfMode(conf *skycoin.NodeConfig) { } } -var ( - trackerAddr = "http://127.0.0.1:9091" // cx tracker address - dmsgDiscAddr = cxdmsg.DefaultDiscAddr // dmsg discovery address - dmsgPort = uint64(cxdmsg.DefaultPort) // dmsg listening port -) - -func init() { - cmd := flag.CommandLine - cmd.StringVar(&trackerAddr, "cx-tracker", trackerAddr, "HTTP `ADDRESS` of cx tracker") - cmd.StringVar(&dmsgDiscAddr, "dmsg-disc", dmsgDiscAddr, "HTTP `ADDRESS` of dmsg discovery") - cmd.Uint64Var(&dmsgPort, "dmsg-port", dmsgPort, "dmsg `PORT` number to listen on") -} - +// trackerUpdateLoop updates the cx tracker of the current node state. func trackerUpdateLoop(nodeSK cipher.SecKey, nodeTCPAddr string, spec cxspec.ChainSpec) { log := logging.MustGetLogger("cx_tracker_client") - client := cxspec.NewCXTrackerClient(log, nil, trackerAddr) + client := cxspec.NewCXTrackerClient(log, nil, specFlags.CXTracker) nodePK := cipher.MustPubKeyFromSecKey(nodeSK) block, err := spec.GenerateGenesisBlock() @@ -165,9 +159,20 @@ func trackerUpdateLoop(nodeSK cipher.SecKey, nodeTCPAddr string, spec cxspec.Cha } func main() { - // Parse chain spec file and secret key from envs. - spec := parseSpecFilepathEnv() // Chain spec file (mandatory). - nodeSK := parseSecretKeyEnv() // Secret Key file (mandatory). + // Register and parse flags for cx chain spec. + spec := locateSpec() + cxspec.PopulateParamsModule(spec) + log.Info(spec.PrintString()) + + // Register additional CLI flags. + cmd := flag.CommandLine + cmd.StringVar(&dmsgDiscAddr, "dmsg-disc", dmsgDiscAddr, "HTTP `ADDRESS` of dmsg discovery") + cmd.Uint64Var(&dmsgPort, "dmsg-port", dmsgPort, "dmsg `PORT` number to listen on") + cmd.BoolVar(&forceClient, "client", forceClient, "force client mode (even with master sk set)") + + // Parse ENV for node secret key. + nodeSK := parseSecretKeyEnv() + var nodePK cipher.PubKey // Node config: Init. @@ -175,13 +180,12 @@ func main() { ensureConfMode(&conf) // Node config: Populate node config based on chain spec content. - if err := cxspec.PopulateNodeConfig(trackerAddr, spec, &conf); err != nil { + if err := cxspec.PopulateNodeConfig(specFlags.CXTracker, spec, &conf); err != nil { log.WithError(err).Fatal("Failed to parse from chain spec file.") } - // Node config: Check node secret key. + // Node config: Ensure node keys are set. // - If node secret key is null, randomly generate one. - // - If node secret key generates spec's chain pk, it is also the chain's publisher node. if nodeSK.Null() { nodePK, nodeSK = cipher.GenerateKeyPair() log.WithField("node_pk", nodePK.Hex()). @@ -190,12 +194,17 @@ func main() { if err := nodeSK.Verify(); err != nil { log.WithError(err).Fatal("Failed to verify provided node secret key.") } - if nodePK = cipher.MustPubKeyFromSecKey(nodeSK); nodePK == spec.ProcessedChainPubKey() { + nodePK = cipher.MustPubKeyFromSecKey(nodeSK) + + // Node config: Enable publisher mode if conditions are met. + // - Skip if 'forceClient' is set. + // - Skip if 'nodePK' is not equal to chain spec's PK. + if !forceClient && nodePK == spec.ProcessedChainPubKey() { conf.BlockchainSeckeyStr = nodeSK.Hex() conf.RunBlockPublisher = true } - // Node config: Parse flag set. + // Node config: Register node flags and parse entire flag set. conf.RegisterFlags(flag.CommandLine) flag.Parse() @@ -208,7 +217,7 @@ func main() { }, }, log) - // This is, despite the name, post-processing and not "parsing". + // This is post-processing and not "parsing" (despite the name). // Do not get confused. I did not name this function. <3 @evanlinjin if err := coin.ParseConfig(flag.CommandLine); err != nil { log.Error(err) @@ -219,35 +228,43 @@ func main() { defer close(gwCh) go func() { + // await gateway to be loaded and ready gw, ok := <-gwCh if !ok { return } // Run cx tracker loop. + // - Node params are uploaded to the tracker. + // - Send keepalive requests on regular intervals. + // 'addr' is the daemon IP address dConf := gw.DaemonConfig() addr := fmt.Sprintf("%s:%d", dConf.Address, dConf.Port) go trackerUpdateLoop(nodeSK, addr, spec) + // Prepare API to be served via dmsg. + dmsgAPI := &cxdmsg.API{ + Version: version, + NodeConf: conf, + ChainSpec: spec, + Gateway: gw, + } + + // Prepare dmsg config. + dmsgConf := &cxdmsg.Config{ + PK: cipher2.PubKey(nodePK), + SK: cipher2.SecKey(nodeSK), + DiscAddr: dmsgDiscAddr, + DmsgPort: uint16(dmsgPort), + } + // Run dmsg loop. - cxdmsg.ServeDmsg( - context.Background(), - logging.MustGetLogger("dmsgC"), - &cxdmsg.Config{ - PK: cipher2.PubKey(nodePK), - SK: cipher2.SecKey(nodeSK), - DiscAddr: dmsgDiscAddr, - DmsgPort: uint16(dmsgPort), - }, - &cxdmsg.API{ - Version: version, - NodeConf: conf, - ChainSpec: spec, - Gateway: gw, - }, - ) + dmsgCtx := context.Background() + dmsgLog := logging.MustGetLogger("dmsgC") + cxdmsg.ServeDmsg(dmsgCtx, dmsgLog, dmsgConf, dmsgAPI) }() + // Run main daemon. if err := coin.Run(spec.RawGenesisProgState(), gwCh); err != nil { os.Exit(1) } diff --git a/doc/EXAMPLE.md b/doc/EXAMPLE.md new file mode 100644 index 000000000..1c5716d45 --- /dev/null +++ b/doc/EXAMPLE.md @@ -0,0 +1,28 @@ +# Example Environment + +Create temporary working directory. + +```bash +$ mkdir temp +``` + +Run `cx-tracker` in a terminal. + +```bash +$ cx-tracker --db="temp/cx-tracker.db" --addr=":9091" +``` + +Generate chain spec file. + +```bash +$ cxchain-cli new \ + --coin="tempcoin" \ + --chain-keys-output="temp/tempcoin.chain_keys.json" \ + --chain-spec-output="temp/tempcoin.chain_spec.json" \ + --genesis-keys-output="temp/tempcoin.genesis_keys.json" \ + ./cx/examples/counter-bc.cx +``` + +Run publisher node. + +> TODO @evanlinjin: Make cxchain-cli target to extract keys. \ No newline at end of file diff --git a/integration/env.sh b/integration/env.sh new file mode 100755 index 000000000..42417e60c --- /dev/null +++ b/integration/env.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash + +# Enable executed commands to be displayed. +#set -x + +# Initiate environment. +function init_env() { + echo "<< Initiating environment. >>" + + # Ensure GOBIN is set. + : "${GOBIN:="$HOME/go/bin"}" + echo "GOBIN=$GOBIN" + + # Ensure TEMP_DIR is set. + : "${TEMP_DIR:=$(mktemp -d 2>/dev/null || mktemp -d -t 'cx_chains_integration')}" + echo "TEMP_DIR=$TEMP_DIR" + + # Ensure TRACKER_SRC is set. + : "${TRACKER_SRC=$(dirname "$(pwd)")/cx-tracker}" + echo "TRACKER_SRC=$TRACKER_SRC" + + # Ensure TRACKER_ADDR is set. + : "${TRACKER_ADDR:=":9091"}" + echo "TRACKER_ADDR=$TRACKER_ADDR" + + # Ensure TRACKER_DB is set. + : "${TRACKER_DB:="$TEMP_DIR/cx_tracker.db"}" + echo "TRACKER_DB=$TRACKER_DB" + + # Ensure TRACKER_LOG is set. + : "${TRACKER_LOG:="$TEMP_DIR/cx_tracker.log"}" + echo "TRACKER_LOG=$TRACKER_LOG" + + # Ensure TRACKER_PID is set. + : "${TRACKER_PID:="$TEMP_DIR/cx_tracker.pid"}" + echo "TRACKER_PID=$TRACKER_PID" + + # Ensure CXCHAIN_DIR is set. + : "${CXCHAIN_DIR:="$TEMP_DIR/cxchain"}" + +} + +# Initiate binaries. +function init_bin() { + echo "<< Initiating binaries. >>" + + # Compile cx-tracker. + _d=$(pwd) + cd "$TRACKER_SRC" || exit 1 + echo ">> Installing 'cx-tracker'." + make install || exit 1 + cd "$_d" || exit 1 + + # Compile cxchain. + echo ">> Installing 'cxchain'." + make install || exit 1 +} + +# Clean temp dir. +function clean_temp_dir() { + echo "<< Cleaning temporary directory. >>" + rm -rf "$TEMP_DIR" +} + +# Start tracker. +function start_tracker() { + echo "<< Starting 'cx-tracker'. >>" + "$GOBIN/cx-tracker" --db="$TRACKER_DB" --addr="$TRACKER_ADDR" >> "$TRACKER_LOG" 2>&1 & + echo $$ > "$TRACKER_PID" +} + +# Stop tracker. +function stop_tracker() { + echo "<< Stopping 'cx-tracker'. >>" + cat "$TRACKER_PID" || xargs kill +} + +# Start cxchain client. +function start_cxchain_client() { + echo "<< Starting 'cxchain'. >>" + if [[ "$?" -ne 3 ]]; then echo "Needs 3 arguments." && exit 1; fi + + local _index="$1" + local _port="$2" + local _web_port="$3" + + "$GOBIN/cxchain" --enable-all-api-sets --client \ + --data-dir="$CXCHAIN_DIR/$_index" \ + --port="$_port" \ + --web-interface-port="$_web_port" \ + >> "$TEMP_DIR/cxchain_$_index.log" 2>&1 & + + echo $$ > "$TEMP_DIR/cxchain$_index.pid" +} + +# Start cxchain master. +function start_cxchain_master() { + echo "<< Starting 'cxchain'. >>" + if [[ "$?" -ne 3 ]]; then echo "Needs 3 arguments." && exit 1; fi + + local _index="$1" + local _port="$2" + local _web_port="$3" + + "$GOBIN/cxchain" --enable-all-api-sets \ + --data-dir="$CXCHAIN_DIR/$_index" \ + --port="$_port" \ + --web-interface-port="$_web_port" \ + >> "$TEMP_DIR/cxchain_$_index.log" 2>&1 & + + echo $$ > "$TEMP_DIR/cxchain$_index.pid" +} + +init_env; +init_bin; + +start_tracker; + +sleep 5 + +stop_tracker; + +clean_temp_dir; \ No newline at end of file diff --git a/src/cx/cxspec/chainspec.go b/src/cx/cxspec/chainspec.go index 1d4235267..0787c4b25 100644 --- a/src/cx/cxspec/chainspec.go +++ b/src/cx/cxspec/chainspec.go @@ -204,12 +204,14 @@ func (cs ChainSpec) ProcessedChainPubKey() cipher.PubKey { return cs.chainPK } -func (cs *ChainSpec) Print() { +// PrintString prints an indented json representation of the chain spec. +func (cs *ChainSpec) PrintString() string { b, err := json.MarshalIndent(cs, "", "\t") if err != nil { panic(err) } - fmt.Println(string(b)) + + return string(b) } // SpecHash returns the hashed spec object. diff --git a/src/cx/cxspec/locate.go b/src/cx/cxspec/locate.go new file mode 100644 index 000000000..49e61c4a9 --- /dev/null +++ b/src/cx/cxspec/locate.go @@ -0,0 +1,201 @@ +package cxspec + +import ( + "context" + "errors" + "flag" + "fmt" + "net/http" + "strings" + + "github.com/sirupsen/logrus" + "github.com/skycoin/skycoin/src/cipher" + "github.com/skycoin/skycoin/src/util/logging" +) + +// LocPrefix determines the location type of the location string. +type LocPrefix string + +// Locations types. +const ( + FileLoc = LocPrefix("file") + TrackerLoc = LocPrefix("tracker") +) + +// Constants. +const ( + // DefaultSpecFilepath is the default cx spec filepath. + // This is for internal use. + DefaultSpecFilepath = "skycoin.chain_spec.json" + + // DefaultSpecLocStr is the default cx spec location string. + DefaultSpecLocStr = string(FileLoc + ":" + DefaultSpecFilepath) + + // DefaultTrackerURL is the default cx tracker URL. + DefaultTrackerURL = "https://cxt.skycoin.com" +) + +// Possible errors when executing 'Locate'. +var ( + ErrEmptySpec = errors.New("empty chain spec provided") + ErrEmptyTracker = errors.New("tracker is not provided") + ErrInvalidLocPrefix = errors.New("invalid spec location prefix") +) + +// Locate locates the chain spec given a 'loc' string. +// The 'loc' string is to be of format ':'. +// * is 'tracker' if undefined. +// * either specifies the cx chain's genesis hash (if +// is 'tracker') or filepath of the spec file (if +// is 'file'). +func Locate(ctx context.Context, log logrus.FieldLogger, tracker *CXTrackerClient, loc string) (ChainSpec, error) { + // Ensure logger is existent. + if log == nil { + log = logging.MustGetLogger("cxspec").WithField("func", "Locate") + } + + prefix, suffix, err := splitLocString(loc) + if err != nil { + return ChainSpec{}, err + } + + // Check location prefix (LocPrefix). + switch prefix { + case FileLoc: + if suffix == "" { + suffix = DefaultSpecFilepath + } + + return ReadSpecFile(suffix) + + case TrackerLoc: + // Check that 'tracker' is not nil. + if tracker == nil { + return ChainSpec{}, ErrEmptyTracker + } + + // Obtain genesis hash from hex string. + hash, err := cipher.SHA256FromHex(suffix) + if err != nil { + return ChainSpec{}, fmt.Errorf("invalid genesis hash provided '%s': %w", loc, err) + } + + // Obtain spec from tracker. + signedChainSpec, err := tracker.SpecByGenesisHash(ctx, hash) + if err != nil { + return ChainSpec{}, fmt.Errorf("chain spec not of genesis hash not found in tracker: %w", err) + } + + // Verify again (no harm in doing it twice). + if err := signedChainSpec.Verify(); err != nil { + return ChainSpec{}, err + } + + return signedChainSpec.Spec, nil + + default: + return ChainSpec{}, fmt.Errorf("%w '%s'", ErrInvalidLocPrefix, prefix) + } +} + +func splitLocString(loc string) (prefix LocPrefix, suffix string, err error) { + loc = strings.TrimSpace(loc) + if loc == "" { + return "", "", ErrEmptySpec + } + + locParts := strings.SplitN(loc, ":", 2) + + switch len(locParts) { + case 1: + locParts = append([]string{string(TrackerLoc)}, locParts...) + case 2: + // continue + default: + panic("internal error: Locate() should never return >2 location parts") + } + + return LocPrefix(locParts[0]), locParts[1], nil +} + +// LocateConfig contains flag values for Locate. +type LocateConfig struct { + CXChain string // CX Chain spec location string. + CXTracker string // CX Tracker URL. + + Logger logrus.FieldLogger + HTTPClient *http.Client +} + +// FillDefaults fills LocateConfig with default values. +func (c *LocateConfig) FillDefaults() { + c.CXChain = DefaultSpecLocStr + c.CXTracker = DefaultTrackerURL + c.Logger = logging.MustGetLogger("spec_loc") +} + +// DefaultLocateConfig returns the default LocateConfig set. +func DefaultLocateConfig() LocateConfig { + var lc LocateConfig + lc.FillDefaults() + return lc +} + +// SoftParse parses the OS args for the 'chain' flag. +// It is called 'soft' parse because the existence of non-defined flags does not +// result in failure. +func (c *LocateConfig) SoftParse(args []string) { + if v, ok := obtainFlagValue(args, "chain"); ok { + c.CXChain = v + } + if v, ok := obtainFlagValue(args, "tracker"); ok { + c.CXTracker = v + } +} + +// RegisterFlags ensures that the 'help' menu contains the locate flags and that +// the flags are recognized. +func (c *LocateConfig) RegisterFlags(fs *flag.FlagSet) { + var temp string + fs.StringVar(&temp, "chain", c.CXChain, fmt.Sprintf("cx chain location. Prepend with '%s:' or '%s:' for spec location type.", FileLoc, TrackerLoc)) + fs.StringVar(&temp, "tracker", c.CXTracker, "CX Tracker `URL`.") +} + +// TrackerClient generates a CX Tracker client based on the defined config. +func (c *LocateConfig) TrackerClient() *CXTrackerClient { + return NewCXTrackerClient(c.Logger, c.HTTPClient, c.CXTracker) +} + +// LocateWithConfig locates a spec with a given locate config. +func LocateWithConfig(ctx context.Context, conf *LocateConfig) (ChainSpec, error) { + return Locate(ctx, conf.Logger, conf.TrackerClient(), conf.CXChain) +} + +/* + << Helper functions >> +*/ + +func obtainFlagValue(args []string, key string) (string, bool) { + var ( + keyPrefix1 = "-" + key + keyPrefix2 = keyPrefix1 + "=" + ) + + for i, a := range args { + // Standardize flag prefix to single '-'. + if strings.HasPrefix(a, "--") { + a = a[1:] + } + + // If there is no '=', the flag value is the next arg. + if a == "-"+key && i+1 < len(args) { + return args[i+1], true + } + + if strings.HasPrefix(a, keyPrefix2) { + return strings.TrimPrefix(a, keyPrefix2), true + } + } + + return "", false +} diff --git a/src/cx/cxspec/locate_test.go b/src/cx/cxspec/locate_test.go new file mode 100644 index 000000000..c9ae2e325 --- /dev/null +++ b/src/cx/cxspec/locate_test.go @@ -0,0 +1,84 @@ +package cxspec + +import ( + "flag" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLocate(t *testing.T) { + t.Run("split_loc_string", func(t *testing.T) { + type TestCase struct { + name string // test name + in string // test input + + // expected outputs + prefix LocPrefix + suffix string + hasErr bool + hasPanic bool + } + + cases := []TestCase{ + { + name: "0_parts", + in: "", + hasErr: true, + }, + { + name: "1_parts", + in: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + prefix: TrackerLoc, + suffix: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + }, + { + name: "2_parts", + in: fmt.Sprintf("%s:%s", TrackerLoc, "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"), + prefix: TrackerLoc, + suffix: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + }, + { + name: "2_parts", + in: fmt.Sprintf("%s:%s", FileLoc, `this\:/is/a\:/test/file.json`), + prefix: FileLoc, + suffix: `this\:/is/a\:/test/file.json`, + }, + } + + for _, c := range cases { + c := c + + t.Run(c.name, func(t *testing.T) { + prefix, suffix, err := splitLocString(c.in) + + if c.hasErr { + assert.Error(t, err) + return + } + + assert.Equal(t, c.prefix, prefix) + assert.Equal(t, c.suffix, suffix) + assert.NoError(t, err) + }) + } + }) + + t.Run("temp", func(t *testing.T) { + flag1 := "" + fs1 := flag.NewFlagSet("cmd", flag.ContinueOnError) + fs1.Usage = func() {} + fs1.StringVar(&flag1, "flag1", flag1, "this is the first flag") + + flag2 := "" + fs2 := flag.NewFlagSet("cmd", flag.ContinueOnError) + fs2.StringVar(&flag2, "flag2", flag2, "this is the second flag") + + args := []string{"--flag2=hello"} + + fs1.Parse(args) + require.NoError(t, fs2.Parse(args)) + }) +} diff --git a/src/skycoin/config.go b/src/skycoin/config.go index 9b27e7129..ae9a33da3 100644 --- a/src/skycoin/config.go +++ b/src/skycoin/config.go @@ -39,6 +39,10 @@ type NodeConfig struct { // Name of the coin CoinName string + // TODO @evanlinjin: Define this in a separate struct. + // Location string for cx chain spec (check /src/cx/cxspec module). + // CXChain string + // Disable peer exchange DisablePEX bool // Download peer list @@ -489,7 +493,7 @@ func validateAPISets(opt string, apiSets []string) error { case "": continue default: - return fmt.Errorf("Invalid value in %s: %q", opt, k) + return fmt.Errorf("invalid value in %s: %q", opt, k) } } return nil @@ -501,6 +505,7 @@ func (c *NodeConfig) RegisterFlags(fs *flag.FlagSet) { fs = flag.CommandLine } fs.BoolVar(&help, "help", false, "Show help") + fs.BoolVar(&c.DisablePEX, "disable-pex", c.DisablePEX, "disable PEX peer discovery") fs.BoolVar(&c.DownloadPeerList, "download-peerlist", c.DownloadPeerList, "download a peers.txt from -peerlist-url") fs.StringVar(&c.PeerListURL, "peerlist-url", c.PeerListURL, "with -download-peerlist=true, download a peers.txt file from this url")