com.myodov.unicherrygarden.cherrygardener.CherryGardenerCLI Maven / Gradle / Ivy
Show all versions of cherrygardener_connector_cli Show documentation
package com.myodov.unicherrygarden.cherrygardener;
import com.myodov.unicherrygarden.api.types.MinedTransfer;
import com.myodov.unicherrygarden.api.types.SystemStatus;
import com.myodov.unicherrygarden.api.types.dlt.Currency;
import com.myodov.unicherrygarden.api.types.responseresult.ResponseWithPayload;
import com.myodov.unicherrygarden.connector.api.ClientConnector;
import com.myodov.unicherrygarden.connector.api.Observer;
import com.myodov.unicherrygarden.ethereum.EthUtils;
import com.myodov.unicherrygarden.messages.CherryGardenResponseWithPayload;
import com.myodov.unicherrygarden.messages.cherrygardener.GetCurrencies;
import com.myodov.unicherrygarden.messages.cherrypicker.*;
import org.apache.commons.cli.*;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.web3j.tx.ChainIdLong;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintStream;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.AbstractExecutorService;
import java.util.concurrent.ForkJoinPool;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import static com.myodov.unicherrygarden.StringTools.withOffset;
/**
* Command Line Interface frontend to CherryGardener Connector API.
*/
public class CherryGardenerCLI {
/**
* HOCON conf file in ~/.unicherrygarden/cli.conf
.
*/
final static ConfFile confFile = new ConfFile();
private static final Options options = new Options();
protected static final int DEFAULT_NUMBER_OF_CONFIRMATIONS = 6;
static {
options.addOption(Option.builder("c")
.longOpt("connect")
.hasArgs()
.valueSeparator(',')
.desc("Comma-separated list of addresses to connect;\n" +
"e.g. \"127.0.0.1:2551,127.0.0.1:2552\".")
.build());
options.addOption(
null, "realm", true,
"The \"realm\" text string matching the realm of UniCherryGarden;\n" +
"needed to distinguish between multiple CherryGarden components for different blockchains,\n" +
"running in the same Akka cluster.");
options.addOption(
null, "chain-id", true,
"The integer Chain ID of Ethereum network;\n" +
"needed to create the compatible transactions for the EIP-155 compatibility.\n" +
"Default: 1 (Ethereum Mainnet).");
options.addOption(
null, "confirmations", true,
"The number of confirmations (Ethereum blocks already mined after an event);\n" +
"Should be a non-negative integer number, likely 6 or 12 or more. Default: 6.");
options.addOption(
"lp", "listen-port", true,
"The IP port to listen;\n" +
"Should be a non-negative integer number, 0 to 65535, 0 means autogenerate. " +
"Note the client (at this port) should be reachable to the servers, so if you are using " +
"SSH port forwarding, you may need to forward both local and remote ports. " +
"Default: 0 (autogenerate).");
final OptionGroup commandOptionGroup = new OptionGroup() {{
addOption(new Option(
"h", "help", false,
"Display help."));
addOption(Option.builder("gc")
.longOpt("get-currencies")
.hasArgs()
.optionalArg(true)
.valueSeparator(',')
.desc("Print the list of currencies supported by CherryGardener and all other components.\n" +
"Pass comma-separated currency keys, i.e. lowercased Ethereum addresses of token contracts,\n" +
"or empty string for base blockchain currency (ETH in case of Ethereum Mainnet).\n" +
"E.g.:\n" +
" --get-currencies for all currencies;\n" +
" --get-currencies= for just a single ETH to select;\n" +
" --get-currencies=,0x9e3319636e2126e3c0bc9e3134aec5e1508a46c7 for ETH and UTNP tokens.")
.build());
addOption(new Option(
"gta", "get-tracked-addresses", false,
"Print the list of addresses tracked by CherryPicker."));
addOption(new Option(
"gad", "get-address-details", true,
"Print the details about a single address, tracked or untracked."));
addOption(new Option(
"ata", "add-tracked-address", true,
"Add an Ethereum address to track.\n" +
"Should be a valid Ethereum address;\n" +
"e.g. \"0x884191033518be08616821d7676ca01695698451\".\n" +
"See also:\n" +
"--track-from-block (mandatory),\n" +
"--comment (optional)."));
addOption(Option.builder("gb")
.longOpt("get-balances")
.hasArgs()
.valueSeparator(',')
.desc("Get balances (of currencies/tokens) for an (already tracked) Ethereum address.\n" +
"Should be a valid Ethereum address;\n" +
"e.g. \"0x884191033518be08616821d7676ca01695698451\".\n" +
"See also:\n" +
"--confirmations (optional; default: 0).\n" +
"--parallel (optional; default: omitted, run sequentially).")
.build());
addOption(new Option(
"gt", "get-transfers", false,
"Get the transfers balances (of currencies/tokens).\n" +
"At least one of --sender or --receiver values should be provided, " +
"and must be an already tracked Ethereum address!\n" +
"See also:\n" +
"--confirmations (optional; default: 0);\n" +
"--sender (optional; valid Ethereum address);\n" +
"--receiver (optional; valid Ethereum address);\n" +
"--from-block (optional; default: first available);\n" +
"--to-block (optional; default: last available, but not newer than " +
"the --confirmations value permit);\n" +
"--with-balances (optional; default: omitted)."
));
// addOption(new Option(
// "cot", "create-outgoing-transfer", true,
// "Build a transaction for outgoing transfer of some currency\n" +
// "from some address to some other address.\n" +
// "The transaction is just built (locally, in memory) but not sent out to the blockchain\n" +
// "and not stored anywhere.\n" +
// "See also:\n" +
// "--sender (mandatory),\n" +
// "--recipient (mandatory),\n" +
// "--chain-id (optional, default: 1 for Ethereum Mainnet),\n" +
// "--currency-key (mandatory),\n" +
// "--amount (mandatory),\n" +
// "--comment (optional)."));
// addOption(new Option(
// "st", "sign-transaction", true,
// "Build a transaction for outgoing transfer of some currency\n" +
// "from some address to some other address.\n" +
// "The transaction is just built (locally, in memory) but not sent out to the blockchain\n" +
// "and not stored anywhere.\n" +
// "See also:\n" +
// "--sender (mandatory),\n" +
// "--recipient (mandatory),\n" +
// "--currency-key (mandatory),\n" +
// "--amount (mandatory),\n" +
// "--comment (optional)."));
setRequired(true);
}};
options.addOptionGroup(commandOptionGroup);
options.addOption(
null, "loop", false,
"(For those commands that support it) start infinite loop,\n" +
"repeating the operation over and over.\n" +
"Can be used for benchmark/performance purposes."
);
options.addOption(
null, "comment", true,
"Text comment."
);
options.addOption(
null, "track-from-block", true,
"How to choose a block from which to track the address.\n" +
"Values (enter one of):\n" +
"* – integer number of block, e.g. \"4451131\";\n" +
"* LATEST_KNOWN – latest block known to Ethereum node." /* + "\n" +
"* LATEST_NODE_SYNCED – latest block fully synced by Ethereum node (available to the Ethereum node);\n" +
"* LATEST_CHERRYGARDEN_SYNCED – latest block fully synced by UniCherryGarden." */
);
options.addOption(
"from", "sender", true,
"Sender Ethereum address."
);
options.addOption(
"to", "receiver", true,
"Receiver Ethereum address."
);
options.addOption(
null, "from-block", true,
"First block to use (inclusive) for read operations.\n" +
"Should contain a number of the block (0 or more)."
);
options.addOption(
null, "to-block", true,
"Last block to use (inclusive) for read operations.\n" +
"Should contain a number of the block (0 or more).\n" +
"If --from-block is also present, --to-block value should be greater or equal " +
"to --from-block value."
);
options.addOption(
null, "with-balances", false,
"Whether the request should also retrieve the balances of the mentioned addresses. \n" +
"If present, the balances are requested; if omitted; the balances are not requested."
);
options.addOption(
"par", "parallel", false,
"If multiple arguments passed, specifies whether the queries should run in parallel. " +
"Will run sequentially if omitted."
);
}
protected static final Properties propsCLI = loadPropsFromNamedResource("cherrygardener_connector_cli.properties");
protected static final Properties propsConnector = loadPropsFromNamedResource("cherrygardener_connector.properties");
/**
* Load {@link Properties} from a .properties-formatted file in the artifact resources.
*/
@NonNull
private static Properties loadPropsFromNamedResource(@NonNull String resourceName) {
final String resourcePath = "unicherrygarden/" + resourceName;
final Properties props = new Properties();
final @Nullable InputStream resourceAsStream = CherryGardenerCLI.class.getClassLoader().getResourceAsStream(resourcePath);
if (resourceAsStream == null) {
System.err.printf("Cannot load resource from %s\n", resourcePath);
} else {
try {
props.load(resourceAsStream);
} catch (IOException e) {
System.err.printf("Cannot load properties from %s\n", resourcePath);
throw new RuntimeException(String.format("Cannot load properties file from %s", resourcePath));
}
}
return props;
}
private static Optional _parseListenPort(@NonNull CommandLine line) {
final Optional listenPortConf = confFile.getListenPort();
if (line.hasOption("listen-port")) {
final String optString = line.getOptionValue("listen-port");
final int listenPort;
try {
listenPort = Integer.parseUnsignedInt(optString);
} catch (NumberFormatException e) {
System.err.println("WARNING: --listen-port option should contain an IP port number!");
return Optional.empty();
}
if (!(0 <= listenPort && listenPort <= 65535)) {
System.err.println("WARNING: --listen-port value should be between 0 and 65535 inclusive!");
return Optional.empty();
}
return Optional.of(listenPort);
} else if (listenPortConf.isPresent()) {
// Fallback to the option in conf file
return listenPortConf;
} else {
// Final fallback to the default: 0
System.err.println("Note: --listen-port value is missing, using 0 as default (autogenerate the port). " +
"Please make sure your client at this port is reachable from the server network!");
return Optional.of(0);
}
}
private static Optional> _parseConnectUrls(@NonNull CommandLine line) {
final Optional> connectUrlsConf = confFile.getConnectUrls();
@Nullable final String[] connectEntriesArr = line.getOptionValues("connect");
if (connectEntriesArr != null) {
final List connectUrls = Collections.unmodifiableList(Arrays.asList(connectEntriesArr));
if (connectUrls.isEmpty()) {
System.err.println("WARNING: --connect option must be non-empty! " +
"Recommended 2 or more URLs, like \"127.0.0.1:2551,127.0.0.1:2552\".");
return Optional.empty();
} else {
return Optional.of(connectUrls);
}
} else if (connectUrlsConf.isPresent()) {
// Fallback to the option in conf file
return connectUrlsConf;
} else {
// Final fallback: no defaults
System.err.println("WARNING: --connect option must be present, " +
"or `connect.urls` setting defined in conf file!");
return Optional.empty();
}
}
private static Optional _parseRealm(@NonNull CommandLine line) {
final Optional realmConf = confFile.getRealm();
if (line.hasOption("realm")) {
final String optString = line.getOptionValue("realm");
if (optString.matches("^[-_a-zA-Z0-9]*$")) {
return Optional.of(optString);
} else {
System.err.printf("ERROR: --realm option can contain only latin letters, digits, \"-\" or \"_\" sign. " +
"Currently it is \"%s\".\n",
optString);
return Optional.empty();
}
} else if (realmConf.isPresent()) {
// Fallback to the option in conf file
return realmConf;
} else {
// Final fallback: no defaults
System.err.println("WARNING: --realm option must be present, " +
"or `connect.realm` setting defined in conf file!");
return Optional.empty();
}
}
private static Optional _parseChainId(@NonNull CommandLine line) {
final Optional chainIdConf = confFile.getChainId();
if (line.hasOption("chain-id")) {
final String optString = line.getOptionValue("chain-id");
final long chainId;
try {
chainId = Long.parseLong(optString);
} catch (NumberFormatException e) {
System.err.println("WARNING: --chain-id option should contain an Ethereum Chain ID as a number!");
return Optional.empty();
}
if (!(chainId == -1 || chainId >= 1)) {
System.err.println("WARNING: --chain-id value should be >=1; can be -1 (None) but not recommended!");
return Optional.empty();
}
return Optional.of(chainId);
} else if (chainIdConf.isPresent()) {
// Fallback to the option in conf file
return chainIdConf;
} else {
// Final fallback to the default: 1 (Mainnet)
final long defaultValue = ChainIdLong.MAINNET;
System.err.printf("Note: --chain-id value is missing, using %s as default.\n", defaultValue);
return Optional.of(defaultValue);
}
}
/**
* Parses the "--listen-port", "--connect", "--realm" and "--chain-id" option assuming that:
*
* --listen-port
setting is optional, with 0 as a default (meaning the port
* will be autogenerated).
* (Reason: by default, you will listen on a random part;
* but for some complex configurations, you may need more complex setup).
* --connect
setting is mandatory, should either be provided in command line
* or as a `connect.urls` setting in conf file.
* (Reason: otherwise you won’t even be able to connect to UniCherryGarden cluster).
* --realm
setting is mandatory, should either be provided in command line
* or as a `connect.realm` setting in conf file.
* (Reason: otherwise, in case of multiple UniCherryGarden systems in one cluster, you won’t be able
* to connect to proper one).
* --chain-id
setting is optional, with 1 (Ethereum Mainnet) as default,
* but suggested to be explicitly provided in the command line or as a `blockchain.chain_id` setting in conf file.
* (Reason: otherwise you won’t be able to build the transactions matching the proper network; see EIP-155).
*
*
*
*
* @return non-empty {@link Optional<>} with the connection settings if they have been properly parsed;
* “empty” optional if parsing failed (and all necessary warnings were printed).
*/
private static Optional parseConnectionSettings(@NonNull CommandLine line) {
final Optional listenPortOpt = _parseListenPort(line);
final Optional> connectUrlsOpt = _parseConnectUrls(line);
final Optional realmOpt = _parseRealm(line);
final Optional chainIdOpt = _parseChainId(line);
if (listenPortOpt.isPresent() &&
connectUrlsOpt.isPresent() &&
realmOpt.isPresent() &&
chainIdOpt.isPresent()) {
return Optional.of(new ConnectionSettings(
listenPortOpt.get(),
connectUrlsOpt.get(),
realmOpt.get(),
chainIdOpt.get()
));
} else {
return Optional.empty();
}
}
/**
* Parse an option (with the name of the option passed as the "optionName" argument)
* that should contain an Ethereum address,
* printing all necessary warnings in the process.
*
* @param optionName the name of the option to parse.
* @return non-empty {@link Optional<>} with the parsed (lowercased) Ethereum address
* if it has been properly parsed;
* “empty” optional if parsing failed (and all required warnings were printed).
*/
private static Optional parseEthereumAddressOption(@NonNull CommandLine line,
@NonNull String optionName,
boolean mandatory) {
@Nullable final String address = line.getOptionValue(optionName);
if (address == null) {
if (mandatory) {
System.err.printf("WARNING: --%s option should be present!\n", optionName);
}
return Optional.empty();
} else if (!EthUtils.Addresses.isValidAddress(address)) {
System.err.printf("WARNING: --%s option should contain a valid Ethereum address!\n", optionName);
return Optional.empty();
} else {
return Optional.of(address.toLowerCase());
}
}
/**
* Parse an option (with the name of the option passed as the "optionName" argument)
* that should contain a list of comma-separated Ethereum addresses,
* printing all necessary warnings in the process.
*
* @param optionName the name of the option to parse.
* @return non-empty {@link Optional<>} with the list of parsed (lowercased) Ethereum addresses
* if it has been properly parsed;
* “empty” optional if parsing failed (and all required warnings were printed).
*/
private static Optional> parseEthereumAddressesOption(@NonNull CommandLine line,
@NonNull String optionName,
boolean mandatory,
boolean nonEmpty) {
@Nullable final String[] addressesArr = line.getOptionValues(optionName);
if (addressesArr == null) {
if (mandatory) {
System.err.printf("WARNING: --%s option should be present!\n", optionName);
}
return Optional.empty();
} else {
final List addresses = Collections.unmodifiableList(Arrays.asList(addressesArr));
if (nonEmpty && addresses.isEmpty()) {
System.err.printf("WARNING: --%s option should contain a valid non-empty comma-separated list of Ethereum addresses!\n",
optionName);
return Optional.empty();
} else if (!addresses.stream().allMatch(EthUtils.Addresses::isValidAddress)) {
System.err.printf("WARNING: --%s option should contain a valid comma-separated list of Ethereum addresses!\n",
optionName);
return Optional.empty();
} else {
return Optional.of(
addresses.stream()
.map(String::toLowerCase)
.collect(Collectors.toList())
);
}
}
}
/**
* Parse an option (with the name of the option passed as the "optionName" argument)
* that should contain a valid block number,
* printing all necessary warnings in the process.
*
* @param optionName the name of the option to parse.
* @return non-empty {@link Optional<>} with the parsed Ethereum block number,
* if it has been properly parsed;
* “empty” optional if parsing failed (and all required warnings were printed).
*/
private static Optional parseBlockNumberOption(@NonNull CommandLine line,
@NonNull String optionName,
boolean mandatory) {
@Nullable final String blockNumberCandidate = line.getOptionValue(optionName);
if (blockNumberCandidate == null) {
if (mandatory) {
System.err.printf("WARNING: --%s option should be present!\n", optionName);
}
return Optional.empty();
} else {
final int intValue;
try {
intValue = Integer.parseInt(blockNumberCandidate);
} catch (NumberFormatException e) {
System.err.printf("WARNING: --%s option should contain a valid Ethereum block number!\n", optionName);
return Optional.empty();
}
if (intValue < 0) {
System.err.printf("WARNING: --%s option should be 0 or higher!\n", optionName);
return Optional.empty();
} else {
return Optional.of(intValue);
}
}
}
static class TrackFromBlockOption {
final AddTrackedAddresses.@NonNull StartTrackingAddressMode mode;
@Nullable
final Integer block;
/**
* Constructor.
*/
private TrackFromBlockOption(AddTrackedAddresses.@NonNull StartTrackingAddressMode mode,
@Nullable Integer block) {
assert (mode == AddTrackedAddresses.StartTrackingAddressMode.FROM_BLOCK) == (block != null)
:
String.format("%s:%s", mode, block);
assert (block == null) || (block >= 0) : block;
this.mode = mode;
this.block = block;
}
/**
* Create the {@link TrackFromBlockOption} when you know the specific number of block.
*/
static TrackFromBlockOption fromSpecificBlock(int block) {
assert block >= 0 : block;
return new TrackFromBlockOption(AddTrackedAddresses.StartTrackingAddressMode.FROM_BLOCK, block);
}
/**
* Create the {@link TrackFromBlockOption} when you autodetect the number of the block.
*
* @param mode The mode of detection.
* Use any mode except {@link AddTrackedAddresses.StartTrackingAddressMode#FROM_BLOCK}
* (if you want to use a specific block number, use {@link #fromSpecificBlock(int)} method instead).
*/
static TrackFromBlockOption fromAutoDetectedBlock(AddTrackedAddresses.@NonNull StartTrackingAddressMode mode) {
assert mode != null && mode != AddTrackedAddresses.StartTrackingAddressMode.FROM_BLOCK : mode;
return new TrackFromBlockOption(mode, null);
}
}
/**
* Parse "--track-from-block" option to the result data (as an {@link TrackFromBlockOption} object),
* printing all necessary warnings in the process.
*
* @return non-empty {@link Optional<>} with the parsed "track-from-block" information
* if it has been properly parsed;
* “empty” optional if parsing failed (and all required warnings were printed).
*/
private static Optional parseTrackFromBlock(@NonNull CommandLine line) {
assert line != null;
final @Nullable String trackFromBlockStr = line.getOptionValue("track-from-block");
if (trackFromBlockStr == null) {
System.err.println("WARNING: --track-from-block option is mandatory!");
return Optional.empty();
} else {
switch (trackFromBlockStr.toUpperCase()) {
case "LATEST_KNOWN":
return Optional.of(TrackFromBlockOption.fromAutoDetectedBlock(
AddTrackedAddresses.StartTrackingAddressMode.LATEST_KNOWN_BLOCK));
case "LATEST_NODE_SYNCED":
return Optional.of(TrackFromBlockOption.fromAutoDetectedBlock(
AddTrackedAddresses.StartTrackingAddressMode.LATEST_NODE_SYNCED_BLOCK));
case "LATEST_CHERRYGARDEN_SYNCED":
return Optional.of(TrackFromBlockOption.fromAutoDetectedBlock(
AddTrackedAddresses.StartTrackingAddressMode.LATEST_CHERRYGARDEN_SYNCED_BLOCK));
default:
try {
final int blockNumberCandidate = Integer.parseUnsignedInt(trackFromBlockStr);
assert blockNumberCandidate >= 0 : blockNumberCandidate;
return Optional.of(TrackFromBlockOption.fromSpecificBlock(blockNumberCandidate));
} catch (NumberFormatException e) {
System.err.println("" +
"WARNING: --track-from-block option should contain one of the supported constants, " +
"or non-negative block number!");
return Optional.empty();
}
}
}
}
/**
* Parse "--confirmations" option,
* printing all necessary warnings in the process.
*
* @return non-empty {@link Optional<>} with the parsed number of confirmations,
* if it has been properly parsed (or the option is missing, then the default value is assumed);
* “empty” optional if parsing failed (and all required warnings were printed).
* If the optional parameter is missing in the command line,
* the result is dependent on the "_default" argument:
* if _default is null
,
* the optional will be assumed "empty" (i.e. parsing failed);
* if _default contains some value,
* the optional will be assumed "non-empty" and the default value returned.
*/
private static Optional parseConfirmations(@NonNull CommandLine line,
@Nullable Integer _default) {
assert line != null;
assert (_default == null) || (_default >= 0) : _default;
final @Nullable String confirmationsStr = line.getOptionValue("confirmations");
if (confirmationsStr == null) {
// There is no "--confirmations" option; should we use a default or fail?
if (_default == null) {
System.err.println("WARNING: --confirmations option is mandatory!");
return Optional.empty();
} else {
return Optional.of(_default);
}
} else { // confirmationsStr != null
try {
final int confirmationsCandidate = Integer.parseUnsignedInt(confirmationsStr);
assert confirmationsCandidate >= 0 : confirmationsCandidate;
return Optional.of(confirmationsCandidate);
} catch (NumberFormatException e) {
System.err.println("" +
"WARNING: --confirmations option should contain non-negative number of confirmations!");
return Optional.empty();
}
}
}
/**
* Parse "--confirmations" option,
* printing all necessary warnings in the process.
* The default number of confirmations is used.
*
* @return non-empty {@link Optional<>} with the parsed number of confirmations,
* if it has been properly parsed (or the option is missing, then the default value is assumed);
* “empty” optional if parsing failed (and all required warnings were printed).
*/
private static Optional parseConfirmations(@NonNull CommandLine line) {
return parseConfirmations(line, DEFAULT_NUMBER_OF_CONFIRMATIONS);
}
/**
* Parse an option (with the name of the option passed as the "optionName" argument)
* that should contain a list of currency keys,
* printing all necessary warnings in the process.
*
* @param optionName the name of the option to parse.
* @return non-empty {@link Optional>} with the parsed currency keys
* if it has been properly parsed;
* “empty” optional if parsing failed (and all required warnings were printed).
*/
private static Optional> parseCurrencyKeysOption(@NonNull CommandLine line,
@NonNull String optionName,
boolean mandatory) {
final String[] optionValues = line.getOptionValues(optionName);
if (optionValues == null) {
if (mandatory) {
System.err.printf("WARNING: --%s option should be present!\n", optionName);
}
return Optional.empty();
} else {
final List ckCandidates = Arrays.asList(optionValues);
boolean anyBad = false;
for (final String ckCandidate : ckCandidates) {
if (!ckCandidate.isEmpty() && !EthUtils.Addresses.isValidLowercasedAddress(ckCandidate)) {
anyBad = true;
System.err.printf("WARNING: --%s is not a valid currency key!\n", ckCandidate);
}
}
if (anyBad) {
return Optional.empty();
} else {
return Optional.of(Collections.unmodifiableList(ckCandidates));
}
}
}
/**
* Constructor: analyze the CLI arguments and act accordingly.
*/
public CherryGardenerCLI(@NonNull String[] args) {
final CommandLineParser parser = new DefaultParser();
try {
final CommandLine line = parser.parse(options, args);
if (line.hasOption("help")) {
printHelp();
} else if (line.hasOption("get-currencies")) {
handleGetCurrencies(line);
} else if (line.hasOption("get-tracked-addresses")) {
handleGetTrackedAddresses(line);
} else if (line.hasOption("get-address-details")) {
handleGetAddressDetails(line);
} else if (line.hasOption("add-tracked-address")) {
handleAddTrackedAddress(line);
} else if (line.hasOption("get-balances")) {
handleGetBalances(line);
} else if (line.hasOption("get-transfers")) {
handleGetTransfers(line);
} else {
printHelp();
}
} catch (ParseException exp) {
System.err.printf("ERROR: Parsing failed. Reason: %s\n", exp.getMessage());
printHelp();
}
}
private static void handleGetCurrencies(@NonNull CommandLine line) {
assert line != null;
final Logger logger = LoggerFactory.getLogger(CherryGardenerCLI.class);
printTitle(System.err);
// Mandatory
final @NonNull Optional connectionSettingsOpt = parseConnectionSettings(line);
// Optional
final @NonNull Optional> filterCurrencyKeys = parseCurrencyKeysOption(line, "get-currencies", false);
final boolean loopEnabled = line.hasOption("loop");
if (connectionSettingsOpt.isPresent()) {
System.err.println("Getting supported currencies...");
if (filterCurrencyKeys.isPresent()) {
System.err.printf("Using filter: %s\n", filterCurrencyKeys.get());
}
final @NonNull ConnectionSettings connectionSettings = connectionSettingsOpt.get();
// ChainID is non-essential for GetCurrencies, so let it be just the default.
try (final ClientConnector connector = connectionSettings.createClientConnector()) {
do {
final Instant startTime = Instant.now();
final GetCurrencies.Response response = connector.getCurrencies(
filterCurrencyKeys
// Convert to set for network transmission
.map(list -> new HashSet<>(list))
.orElse(null),
true,
false);
if (response.isFailure()) {
System.err.printf("ERROR: Could not get the currencies! Problem: %s\n",
response.getFailure());
} else {
final GetCurrencies.CurrenciesRequestResultPayload payload = response.getPayloadAsSuccessful();
final List currencies = payload.currencies;
System.err.printf("Supported currencies (%s):\n", currencies.size());
for (final Currency c : currencies) {
final @Nullable String optComment = c.getComment();
System.err.printf(" %s: \"%s\" (transfer gas limit %s) - %s%s\n",
c.getSymbol(),
c.getName(),
c.getTransferGasLimit(),
(c.getCurrencyType() == Currency.CurrencyType.ETH) ?
"Ether cryptocurrency" :
String.format("ERC20 token at %s", c.getDAppAddress()),
(optComment == null) ? "" : String.format(" (%s)", optComment)
);
}
printSystemStatus(payload.systemStatus);
}
System.err.printf("--- Done in %s --\n", Duration.between(startTime, Instant.now()));
} while (loopEnabled);
} catch (Exception e) {
System.err.printf("ERROR: Could not connect to UniCherryGarden! Exception %s\n", e);
logger.error("Execution error", e);
}
}
}
private static void handleGetTrackedAddresses(@NonNull CommandLine line) {
assert line != null;
final Logger logger = LoggerFactory.getLogger(CherryGardenerCLI.class);
printTitle(System.err);
final @NonNull Optional connectionSettingsOpt = parseConnectionSettings(line);
final boolean loopEnabled = line.hasOption("loop");
if (connectionSettingsOpt.isPresent()) {
System.err.println("Getting tracked addresses...");
final @NonNull ConnectionSettings connectionSettings = connectionSettingsOpt.get();
// ChainID is non-essential for GetTrackedAddresses, so let it be just the default.
try (final ClientConnector connector = connectionSettings.createClientConnector()) {
do {
final Instant startTime = Instant.now();
final GetTrackedAddresses.Response response = connector.getObserver().getTrackedAddresses();
if (response.isFailure()) {
System.err.printf("ERROR: Could not get the tracked addresses! Problem: %s\n",
response.getFailure());
} else {
final GetTrackedAddresses.TrackedAddressesRequestResultPayload payload = response.getPayloadAsSuccessful();
final List trackedAddresses = payload.addresses;
System.err.printf("Tracked addresses (%s):\n", trackedAddresses.size());
for (GetTrackedAddresses.TrackedAddressesRequestResultPayload.TrackedAddressInformation addr : trackedAddresses) {
System.err.println(withOffset(2, addr.toHumanString()));
}
}
System.err.printf("--- Done in %s --\n", Duration.between(startTime, Instant.now()));
} while (loopEnabled);
} catch (Exception e) {
System.err.printf("ERROR: Could not connect to UniCherryGarden! Exception %s\n", e);
logger.error("Execution error", e);
}
}
}
private static void handleGetAddressDetails(@NonNull CommandLine line) {
assert line != null;
final Logger logger = LoggerFactory.getLogger(CherryGardenerCLI.class);
printTitle(System.err);
final @NonNull Optional connectionSettingsOpt = parseConnectionSettings(line);
final @NonNull Optional addressOpt = parseEthereumAddressOption(line, "get-address-details", true);
if (true &&
connectionSettingsOpt.isPresent() &&
addressOpt.isPresent()
) {
final @NonNull ConnectionSettings connectionSettings = connectionSettingsOpt.get();
final @NonNull String address = addressOpt.get();
System.err.printf("Getting details about address %s...\n", address);
// ChainID is non-essential for GetAddressDetails, so let it be just the default.
try (final ClientConnector connector = connectionSettings.createClientConnector()) {
final Observer observer = connector.getObserver();
final GetAddressDetails.Response response = observer.getAddressDetails(address);
if (response.isFailure()) {
System.err.printf("ERROR: Could not get the details about address %s! Problem: %s\n",
address, response.getFailure());
} else {
final GetAddressDetails.AddressDetailsRequestResultPayload payload = response.getPayloadAsSuccessful();
final GetAddressDetails.AddressDetailsRequestResultPayload.AddressDetails details = payload.details;
if (details.address.equals(address)) {
System.err.printf("Address %s:\n" +
"---------------------------------------------------\n" +
"Tracked by CherryPicker: %s\n" +
"%s" +
"%s\n",
address,
(details.trackedAddressInformation != null) ? "yes" : "no",
(details.trackedAddressInformation != null) ?
String.format(
" %s\n",
details.trackedAddressInformation.toHumanString()
)
:
"",
details.nonces.toHumanString()
);
} else {
System.err.printf("ERROR: Requested details for address %s, but received for address %s!\n",
address, details.address);
}
}
} catch (Exception e) {
System.err.printf("ERROR: Could not connect to UniCherryGarden! Exception %s\n", e);
logger.error("Execution error", e);
}
}
}
private static void handleAddTrackedAddress(@NonNull CommandLine line) {
assert line != null;
final Logger logger = LoggerFactory.getLogger(CherryGardenerCLI.class);
printTitle(System.err);
final @NonNull Optional connectionSettingsOpt = parseConnectionSettings(line);
final @NonNull Optional addressOpt = parseEthereumAddressOption(line, "add-tracked-address", true);
final @NonNull Optional trackFromBlockOpt = parseTrackFromBlock(line);
final @NonNull Optional commentOpt = Optional.ofNullable(line.getOptionValue("comment"));
if (true &&
connectionSettingsOpt.isPresent() &&
addressOpt.isPresent() &&
trackFromBlockOpt.isPresent()
) {
final @NonNull ConnectionSettings connectionSettings = connectionSettingsOpt.get();
final @NonNull String address = addressOpt.get();
final @NonNull TrackFromBlockOption trackFromBlock = trackFromBlockOpt.get();
System.err.printf("Adding tracked address %s with %s; tracking from %s, %s...\n",
address,
commentOpt.isPresent() ? String.format("comment \"%s\"", commentOpt) : "no comment",
trackFromBlock.mode,
trackFromBlock.block
);
// ChainID is non-essential for AddTrackingAddress, so let it be just the default.
try (final ClientConnector connector = connectionSettings.createClientConnector()) {
final Observer observer = connector.getObserver();
final AddTrackedAddresses.Response response = observer.startTrackingAddress(
address,
trackFromBlock.mode,
trackFromBlock.block,
commentOpt.orElse(null));
if (response.isFailure()) {
System.err.printf("ERROR: Could not add the tracked address %s! Problem: %s\n",
address, response.getFailure());
} else {
final AddTrackedAddresses.AddTrackedAddressesRequestResultPayload payload = response.getPayloadAsSuccessful();
final Set addedAddresses = payload.addresses;
if (addedAddresses.size() == 1 && addedAddresses.stream().findFirst().get().equals(address)) {
System.err.printf("Address %s successfully added!\n", address);
} else {
System.err.printf("ERROR: Address %s failed to add!\n", address);
}
}
} catch (Exception e) {
System.err.printf("ERROR: Could not connect to UniCherryGarden! Exception %s\n", e);
logger.error("Execution error", e);
}
}
}
private static void handleGetBalances(@NonNull CommandLine line) {
assert line != null;
final Logger logger = LoggerFactory.getLogger(CherryGardenerCLI.class);
printTitle(System.err);
final @NonNull Optional connectionSettingsOpt =
parseConnectionSettings(line);
final @NonNull Optional> addressesOpt =
parseEthereumAddressesOption(line, "get-balances", true, true);
final @NonNull Optional confirmationsOpt =
parseConfirmations(line);
final boolean runInParallel = line.hasOption("parallel");
if (true &&
connectionSettingsOpt.isPresent() &&
addressesOpt.isPresent() &&
confirmationsOpt.isPresent()
) {
final @NonNull ConnectionSettings connectionSettings = connectionSettingsOpt.get();
final @NonNull List addresses = addressesOpt.get();
final int confirmations = confirmationsOpt.get();
System.err.printf("Getting balances for %s with %s confirmation(s), %s...\n",
String.join(", ", addresses), confirmations,
(runInParallel ? "in parallel" : "sequentially"));
final AbstractExecutorService executor = new ForkJoinPool(addresses.size());
// ChainID is non-essential for GetBalances, so let it be just the default.
try (final ClientConnector connector = connectionSettings.createClientConnector(confirmations)) {
final Stream addressStream = runInParallel ?
addresses.stream().parallel() :
addresses.stream();
final Instant startTime = Instant.now();
// Multiple queries – probably executed in parallel – lead to multiple responses.
final List responses = executor.submit(() ->
addressStream
.map((final String address) -> connector.getObserver().getAddressBalances(
0, // on top of connector-level confirmations number
address,
null
))
.collect(Collectors.toList())
).get();
if (!responses.stream().allMatch(ResponseWithPayload::isSuccess) || addresses.size() != responses.size()) {
System.err.printf("ERROR: Could not get some of the balances for %s!\n", addresses);
} else {
final Duration duration = Duration.between(startTime, Instant.now());
final List results =
responses.stream().map(CherryGardenResponseWithPayload::getPayloadAsSuccessful).collect(Collectors.toList());
System.err.printf("Received the balances for %s (with %s confirmation(s)), %d result(s) in %s:\n",
addresses, confirmations, results.size(), duration);
IntStream.range(0, results.size()).forEach((int i) -> {
final String address = addresses.get(i);
final GetBalances.BalanceRequestResultPayload payload = results.get(i);
System.err.printf("--- %d of %d: %s\n",
i + 1, results.size(), address);
for (final GetBalances.BalanceRequestResultPayload.CurrencyBalanceFact balanceFact : payload.balances) {
System.err.printf(" %s: %s (at block %s)\n",
balanceFact.currency,
balanceFact.amount,
balanceFact.blockNumber
);
}
printSystemStatus(payload.systemStatus);
});
System.err.println("--- done.");
}
} catch (Exception e) {
System.err.printf("ERROR: Could not connect to UniCherryGarden! Exception %s\n", e);
logger.error("Execution error", e);
} finally {
executor.shutdownNow();
}
}
}
private static void handleGetTransfers(@NonNull CommandLine line) {
assert line != null;
final Logger logger = LoggerFactory.getLogger(CherryGardenerCLI.class);
printTitle(System.err);
final @NonNull Optional connectionSettingsOpt = parseConnectionSettings(line);
final @NonNull Optional confirmationsOpt = parseConfirmations(line);
final @NonNull Optional senderOpt = parseEthereumAddressOption(line, "sender", false);
final @NonNull Optional receiverOpt = parseEthereumAddressOption(line, "receiver", false);
final @NonNull Optional fromBlockOpt = parseBlockNumberOption(line, "from-block", false);
final @NonNull Optional toBlockOpt = parseBlockNumberOption(line, "to-block", false);
final boolean withBalances = line.hasOption("with-balances");
if (true &&
connectionSettingsOpt.isPresent() &&
confirmationsOpt.isPresent()
) {
final @NonNull ConnectionSettings connectionSettings = connectionSettingsOpt.get();
final int confirmations = confirmationsOpt.get();
// Extra validations to co-validate the arguments
final boolean coValid;
{
final Supplier coValidator = (() -> {
if (!senderOpt.isPresent() && !receiverOpt.isPresent()) {
System.err.println("ERROR: at least one of --sender or --receiver must be defined!");
return false;
}
if (fromBlockOpt.isPresent() && toBlockOpt.isPresent()) {
final int fromBlock = fromBlockOpt.get();
final int toBlock = toBlockOpt.get();
if (toBlock < fromBlock) {
System.err.println("ERROR: --from-block value must be <= --to-block value!");
return false;
}
}
// In any other case, all the arguments are valid together.
return true;
});
coValid = coValidator.get();
}
// All error messages were printed already by coValidator, so we don’t handle the `if !coValid` case at all
if (coValid) {
@NonNull final String transfersDescription = String.format("%s%s%s%swith %s confirmation(s)",
(senderOpt.isPresent() ? String.format("from %s ", senderOpt.get()) : ""),
(receiverOpt.isPresent() ? String.format("to %s ", receiverOpt.get()) : ""),
(fromBlockOpt.isPresent() ? String.format("from block %s ", fromBlockOpt.get()) : ""),
(toBlockOpt.isPresent() ? String.format("to block %s ", toBlockOpt.get()) : ""),
confirmations);
System.err.printf("Getting transfers %s...\n", transfersDescription);
// ChainID is non-essential for GetTransfers, so let it be just the default.
try (final ClientConnector connector = connectionSettings.createClientConnector(confirmations)) {
final GetTransfers.Response response = connector.getObserver().getTransfers(
0, // on top of connector-level confirmations number
senderOpt.orElse(null),
receiverOpt.orElse(null),
fromBlockOpt.orElse(null),
toBlockOpt.orElse(null),
null,
withBalances
);
if (response.isFailure()) {
System.err.printf("ERROR: Could not add the transfers! Problem: %s\n",
response.getFailure());
} else {
final GetTransfers.TransfersRequestResultPayload payload = response.getPayloadAsSuccessful();
System.err.printf("Received the transfers %s:\n", transfersDescription);
for (final MinedTransfer tr : payload.transfers) {
final String currencyName = tr.currencyKey.isEmpty() ? "ETH" : tr.currencyKey;
System.err.printf(" * %s %s from %s to %s (in tx %s from block %d), fees %s.\n",
tr.amount, currencyName, tr.from, tr.to,
tr.tx.txhash, tr.tx.block.blockNumber,
tr.tx.fees
);
}
printSystemStatus(payload.systemStatus);
}
} catch (Exception e) {
System.err.printf("ERROR: Could not connect to UniCherryGarden! Exception %s\n", e);
logger.error("Execution error", e);
}
}
}
}
private static void printTitle(@NonNull PrintStream printStream) {
final String header = String.format("CherryGardener CLI v. %s", propsCLI.getProperty("version"));
final String underline = String.join(
"",
Collections.nCopies(header.length(), "-")
);
printStream.printf("" +
"%s\n" + // CherryGardener CLI v. 1.23...
"%s\n" + // -----
"CLI: version %s, built at %s\n" +
"CherryGardener connector: version %s, built at %s\n",
header,
underline,
propsCLI.getProperty("version"), propsCLI.getProperty("build_timestamp"),
propsConnector.getProperty("version"), propsConnector.getProperty("build_timestamp")
);
}
private static void printSystemStatus(@NonNull SystemStatus systemStatus) {
System.err.printf("" +
"Overall status, as of %s:\n" +
"%s\n" +
"%s\n",
systemStatus.actualAt,
withOffset(2,
(systemStatus.blockchain != null) ?
systemStatus.blockchain.toHumanString() :
"Blockchain: N/A"
),
withOffset(2,
(systemStatus.cherryPicker != null) ?
systemStatus.cherryPicker.toHumanString() :
"UniCherryPicker: N/A"
)
);
}
private static void printHelp() {
final HelpFormatter formatter = new HelpFormatter();
formatter.setOptionComparator(null); // don’t sort the options
formatter.printHelp("java -jar cherrygardener", options);
}
public static void main(String[] args) {
new CherryGardenerCLI(args);
}
}