diff --git a/hiero-enterprise-cli-sample/README.md b/hiero-enterprise-cli-sample/README.md new file mode 100644 index 00000000..3dedd6fd --- /dev/null +++ b/hiero-enterprise-cli-sample/README.md @@ -0,0 +1,55 @@ +# Hiero Enterprise CLI Sample + +A command-line interface for interacting with the Hiero network using [picocli](https://picocli.info) and `hiero-enterprise-base`. + +## Prerequisites + +- Java 21+ +- Maven 3.8+ +- A Hedera testnet account ([get one free](https://portal.hedera.com)) + +## Build + +```bash +mvn clean package -pl hiero-enterprise-cli-sample -am -DskipTests +``` + +## Usage + +```bash +java -jar hiero-enterprise-cli-sample/target/hiero-enterprise-cli-sample-*.jar [command] +``` + +### Create Account +```bash +java -jar target/*.jar create-account \ + --account-id 0.0.123 \ + --private-key +``` + +### Create Topic +```bash +java -jar target/*.jar create-topic \ + --account-id 0.0.123 \ + --private-key \ + --memo "My first topic" +``` + +### Send Message to Topic +```bash +java -jar target/*.jar send-message \ + --account-id 0.0.123 \ + --private-key \ + --topic-id 0.0.456 \ + --message "Hello Hiero!" +``` + +## Configuration + +Set the following environment variables or pass them as CLI options: + +| Variable | Description | +|----------|-------------| +| `--account-id` | Your Hedera operator account ID (e.g. `0.0.123`) | +| `--private-key` | Your Hedera operator private key | +| `--network` | Network name (default: `hedera-testnet`) | diff --git a/hiero-enterprise-cli-sample/pom.xml b/hiero-enterprise-cli-sample/pom.xml new file mode 100644 index 00000000..1d9a4f5a --- /dev/null +++ b/hiero-enterprise-cli-sample/pom.xml @@ -0,0 +1,61 @@ + + + 4.0.0 + + + org.hiero + hiero-enterprise + 0.20.0-SNAPSHOT + ../pom.xml + + + hiero-enterprise-cli-sample + Hiero Enterprise CLI Sample + Sample for Hiero via CLI using picocli + https://github.com/hiero-ledger/hiero-enterprise-java + + + + ${project.groupId} + hiero-enterprise-base + + + info.picocli + picocli + 4.7.6 + + + io.grpc + grpc-okhttp + + + io.grpc + grpc-inprocess + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + org.hiero.base.sample.HieroCli + + + + + + org.apache.maven.plugins + maven-deploy-plugin + + true + + + + + diff --git a/hiero-enterprise-cli-sample/src/main/java/org/hiero/base/sample/CliHieroContext.java b/hiero-enterprise-cli-sample/src/main/java/org/hiero/base/sample/CliHieroContext.java new file mode 100644 index 00000000..3dd864ae --- /dev/null +++ b/hiero-enterprise-cli-sample/src/main/java/org/hiero/base/sample/CliHieroContext.java @@ -0,0 +1,55 @@ +package org.hiero.base.sample; + +import com.hedera.hashgraph.sdk.AccountId; +import com.hedera.hashgraph.sdk.Client; +import com.hedera.hashgraph.sdk.PrivateKey; +import com.hedera.hashgraph.sdk.PublicKey; +import java.util.HashMap; +import java.util.Map; +import org.hiero.base.HieroContext; +import org.hiero.base.config.NetworkSettings; +import org.hiero.base.data.Account; +import org.jspecify.annotations.NonNull; + +public class CliHieroContext implements HieroContext { + + private final Account operationalAccount; + private final Client client; + + public CliHieroContext(final String accountIdStr, final String privateKeyStr, final String network) { + final AccountId accountId = AccountId.fromString(accountIdStr); + final PrivateKey privateKey = PrivateKey.fromString(privateKeyStr); + final PublicKey publicKey = privateKey.getPublicKey(); + operationalAccount = new Account(accountId, publicKey, privateKey); + + final NetworkSettings networkSettings = + NetworkSettings.forIdentifier(network) + .orElseThrow( + () -> new IllegalStateException("Unknown network: " + network)); + + final Map nodes = new HashMap<>(); + networkSettings + .getConsensusNodes() + .forEach(n -> nodes.put(n.getAddress(), n.getAccountId())); + + client = Client.forNetwork(nodes); + if (!networkSettings.getMirrorNodeAddresses().isEmpty()) { + try { + client.setMirrorNetwork(networkSettings.getMirrorNodeAddresses().stream().toList()); + } catch (InterruptedException e) { + throw new RuntimeException("Error configuring Mirror Node", e); + } + } + client.setOperator(accountId, privateKey); + } + + @Override + public @NonNull Account getOperatorAccount() { + return operationalAccount; + } + + @Override + public @NonNull Client getClient() { + return client; + } +} diff --git a/hiero-enterprise-cli-sample/src/main/java/org/hiero/base/sample/CreateAccountCommand.java b/hiero-enterprise-cli-sample/src/main/java/org/hiero/base/sample/CreateAccountCommand.java new file mode 100644 index 00000000..7099a9b3 --- /dev/null +++ b/hiero-enterprise-cli-sample/src/main/java/org/hiero/base/sample/CreateAccountCommand.java @@ -0,0 +1,36 @@ +package org.hiero.base.sample; + +import org.hiero.base.AccountClient; +import org.hiero.base.implementation.AccountClientImpl; +import org.hiero.base.implementation.ProtocolLayerClientImpl; +import org.hiero.base.protocol.ProtocolLayerClient; +import org.hiero.base.data.Account; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command(name = "create-account", description = "Create a new Hiero account", mixinStandardHelpOptions = true) +public class CreateAccountCommand implements Runnable { + + @Option(names = {"-n", "--network"}, description = "Hiero network", defaultValue = "hedera-testnet") + private String network; + + @Option(names = {"-a", "--account-id"}, description = "Operator account ID", required = true) + private String accountId; + + @Option(names = {"-k", "--private-key"}, description = "Operator private key", required = true) + private String privateKey; + + @Override + public void run() { + try { + final CliHieroContext context = new CliHieroContext(accountId, privateKey, network); + final ProtocolLayerClient protocolClient = new ProtocolLayerClientImpl(context); + final AccountClient accountClient = new AccountClientImpl(protocolClient); + System.out.println("Creating account on " + network + "..."); + final Account account = accountClient.createAccount(); + System.out.println("Account created: " + account.accountId()); + } catch (final Exception e) { + System.err.println("Error: " + e.getMessage()); + } + } +} diff --git a/hiero-enterprise-cli-sample/src/main/java/org/hiero/base/sample/CreateTopicCommand.java b/hiero-enterprise-cli-sample/src/main/java/org/hiero/base/sample/CreateTopicCommand.java new file mode 100644 index 00000000..92971d6e --- /dev/null +++ b/hiero-enterprise-cli-sample/src/main/java/org/hiero/base/sample/CreateTopicCommand.java @@ -0,0 +1,39 @@ +package org.hiero.base.sample; + +import com.hedera.hashgraph.sdk.TopicId; +import org.hiero.base.TopicClient; +import org.hiero.base.implementation.ProtocolLayerClientImpl; +import org.hiero.base.implementation.TopicClientImpl; +import org.hiero.base.protocol.ProtocolLayerClient; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command(name = "create-topic", description = "Create a new Hiero topic", mixinStandardHelpOptions = true) +public class CreateTopicCommand implements Runnable { + + @Option(names = {"-n", "--network"}, description = "Hiero network", defaultValue = "hedera-testnet") + private String network; + + @Option(names = {"-a", "--account-id"}, description = "Operator account ID", required = true) + private String accountId; + + @Option(names = {"-k", "--private-key"}, description = "Operator private key", required = true) + private String privateKey; + + @Option(names = {"-m", "--memo"}, description = "Topic memo", defaultValue = "Created via Hiero CLI") + private String memo; + + @Override + public void run() { + try { + final CliHieroContext context = new CliHieroContext(accountId, privateKey, network); + final ProtocolLayerClient protocolClient = new ProtocolLayerClientImpl(context); + final TopicClient topicClient = new TopicClientImpl(protocolClient, context.getOperatorAccount()); + System.out.println("Creating topic on " + network + "..."); + final TopicId topicId = topicClient.createTopic(memo); + System.out.println("Topic created: " + topicId); + } catch (final Exception e) { + System.err.println("Error: " + e.getMessage()); + } + } +} diff --git a/hiero-enterprise-cli-sample/src/main/java/org/hiero/base/sample/HieroCli.java b/hiero-enterprise-cli-sample/src/main/java/org/hiero/base/sample/HieroCli.java new file mode 100644 index 00000000..c7c97aed --- /dev/null +++ b/hiero-enterprise-cli-sample/src/main/java/org/hiero/base/sample/HieroCli.java @@ -0,0 +1,27 @@ +package org.hiero.base.sample; + +import picocli.CommandLine; +import picocli.CommandLine.Command; + +@Command( + name = "hiero", + mixinStandardHelpOptions = true, + version = "1.0", + description = "Hiero Enterprise CLI - interact with the Hiero network", + subcommands = { + CreateAccountCommand.class, + CreateTopicCommand.class, + SendMessageCommand.class + }) +public class HieroCli implements Runnable { + + public static void main(final String[] args) { + final int exitCode = new CommandLine(new HieroCli()).execute(args); + System.exit(exitCode); + } + + @Override + public void run() { + CommandLine.usage(this, System.out); + } +} diff --git a/hiero-enterprise-cli-sample/src/main/java/org/hiero/base/sample/SendMessageCommand.java b/hiero-enterprise-cli-sample/src/main/java/org/hiero/base/sample/SendMessageCommand.java new file mode 100644 index 00000000..95d792e0 --- /dev/null +++ b/hiero-enterprise-cli-sample/src/main/java/org/hiero/base/sample/SendMessageCommand.java @@ -0,0 +1,41 @@ +package org.hiero.base.sample; + +import org.hiero.base.TopicClient; +import org.hiero.base.implementation.ProtocolLayerClientImpl; +import org.hiero.base.implementation.TopicClientImpl; +import org.hiero.base.protocol.ProtocolLayerClient; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +@Command(name = "send-message", description = "Send a message to a Hiero topic", mixinStandardHelpOptions = true) +public class SendMessageCommand implements Runnable { + + @Option(names = {"-n", "--network"}, description = "Hiero network", defaultValue = "hedera-testnet") + private String network; + + @Option(names = {"-a", "--account-id"}, description = "Operator account ID", required = true) + private String accountId; + + @Option(names = {"-k", "--private-key"}, description = "Operator private key", required = true) + private String privateKey; + + @Option(names = {"-t", "--topic-id"}, description = "Topic ID", required = true) + private String topicId; + + @Option(names = {"-msg", "--message"}, description = "Message content", required = true) + private String message; + + @Override + public void run() { + try { + final CliHieroContext context = new CliHieroContext(accountId, privateKey, network); + final ProtocolLayerClient protocolClient = new ProtocolLayerClientImpl(context); + final TopicClient topicClient = new TopicClientImpl(protocolClient, context.getOperatorAccount()); + System.out.println("Sending message to topic " + topicId + "..."); + topicClient.submitMessage(topicId, message); + System.out.println("Message sent successfully!"); + } catch (final Exception e) { + System.err.println("Error: " + e.getMessage()); + } + } +} diff --git a/hiero-enterprise-microprofile/src/main/java/org/hiero/microprofile/ClientProvider.java b/hiero-enterprise-microprofile/src/main/java/org/hiero/microprofile/ClientProvider.java index c3f64baa..882a42ad 100644 --- a/hiero-enterprise-microprofile/src/main/java/org/hiero/microprofile/ClientProvider.java +++ b/hiero-enterprise-microprofile/src/main/java/org/hiero/microprofile/ClientProvider.java @@ -1,6 +1,7 @@ package org.hiero.microprofile; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import org.eclipse.microprofile.config.inject.ConfigProperties; @@ -9,6 +10,7 @@ import org.hiero.base.FungibleTokenClient; import org.hiero.base.HieroContext; import org.hiero.base.HookClient; +import org.hiero.base.interceptors.ReceiveRecordInterceptor; import org.hiero.base.NftClient; import org.hiero.base.SmartContractClient; import org.hiero.base.TopicClient; @@ -70,8 +72,14 @@ HieroContext createHieroContext(@NonNull final HieroConfig hieroConfig) { @NonNull @Produces @ApplicationScoped - ProtocolLayerClient createProtocolLayerClient(@NonNull final HieroContext hieroContext) { - return new ProtocolLayerClientImpl(hieroContext); + ProtocolLayerClient createProtocolLayerClient( + @NonNull final HieroContext hieroContext, + @NonNull final Instance interceptor) { + final ProtocolLayerClientImpl client = new ProtocolLayerClientImpl(hieroContext); + if (!interceptor.isUnsatisfied()) { + client.setRecordInterceptor(interceptor.get()); + } + return client; } @NonNull diff --git a/hiero-enterprise-microprofile/src/main/java/org/hiero/microprofile/implementation/ContractVerificationClientImpl.java b/hiero-enterprise-microprofile/src/main/java/org/hiero/microprofile/implementation/ContractVerificationClientImpl.java index 1664cab2..6f74d5e2 100644 --- a/hiero-enterprise-microprofile/src/main/java/org/hiero/microprofile/implementation/ContractVerificationClientImpl.java +++ b/hiero-enterprise-microprofile/src/main/java/org/hiero/microprofile/implementation/ContractVerificationClientImpl.java @@ -9,6 +9,8 @@ import jakarta.json.stream.JsonParserFactory; import jakarta.ws.rs.client.Client; import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.io.IOException; import java.io.StringReader; @@ -44,21 +46,64 @@ private String getChainId() throws HieroException { .orElseThrow(() -> new HieroException("Chain ID is not set")); } + private void handleError(@NonNull final Response response) throws IOException { + final String body = response.readEntity(String.class); + throw new IOException("Error response: " + body); + } + + private ContractVerificationState parseStatus(@NonNull final String status) { + if (status.equals("perfect")) { + return ContractVerificationState.FULL; + } else if (status.equals("false")) { + return ContractVerificationState.NONE; + } else { + throw new RuntimeException("Unknown status: " + status); + } + } + @NonNull @Override - public ContractVerificationState checkVerification(@NonNull ContractId contractId) + public ContractVerificationState checkVerification(@NonNull final ContractId contractId) throws HieroException { - throw new UnsupportedOperationException("Not implemented"); - } + Objects.requireNonNull(contractId, "contractId must not be null"); - private void handleError(@NonNull final Response response) throws IOException { - final String body = response.readEntity(String.class); - throw new IOException("Error response: " + body); + final String uri = + CONTRACT_VERIFICATION_URL + + "/check-by-addresses" + + "?addresses=" + + contractId.toSolidityAddress() + + "&chainIds=" + + getChainId(); + + try { + final Response response = + webClient.target(uri).request(MediaType.APPLICATION_JSON).get(); + if (response.getStatusInfo().getFamily() != Response.Status.Family.SUCCESSFUL) { + handleError(response); + } + final String resultBody = response.readEntity(String.class); + final JsonParser parser = jsonParserFactory.createParser(new StringReader(resultBody)); + final JsonArray root = parser.getArray(); + final List results = + root.stream() + .filter(v -> v.getValueType() == JsonValue.ValueType.OBJECT) + .map(JsonValue::asJsonObject) + .toList(); + if (results.size() != 1) { + throw new RuntimeException("Expected exactly one result, got " + results.size()); + } + final String status = results.get(0).getString("status"); + return parseStatus(status); + } catch (final Exception e) { + throw new HieroException("Error verification step", e); + } } @Override public boolean checkVerification( - @NonNull ContractId contractId, @NonNull String fileName, @NonNull String fileContent) + @NonNull final ContractId contractId, + @NonNull final String fileName, + @NonNull final String fileContent) throws HieroException { final ContractVerificationState state = checkVerification(contractId); if (state != ContractVerificationState.FULL) { @@ -79,17 +124,16 @@ public boolean checkVerification( final JsonArray root = parser.getArray(); final List results = root.stream() - .filter(JsonValue.ValueType.OBJECT::equals) + .filter(v -> v.getValueType() == JsonValue.ValueType.OBJECT) .map(JsonValue::asJsonObject) .filter(obj -> obj.getString("name").equals(fileName)) .toList(); if (results.size() != 1) { throw new RuntimeException("Expected exactly one result, got " + results.size()); } - final JsonObject result = results.get(0); - final String content = result.getString("content"); + final String content = results.get(0).getString("content"); return Objects.equals(content, fileContent); - } catch (Exception e) { + } catch (final Exception e) { throw new HieroException("Error verification step", e); } } @@ -97,10 +141,47 @@ public boolean checkVerification( @NonNull @Override public ContractVerificationState verify( - @NonNull ContractId contractId, - @NonNull String contractName, - @NonNull Map files) + @NonNull final ContractId contractId, + @NonNull final String contractName, + @NonNull final Map files) throws HieroException { - throw new UnsupportedOperationException("Not implemented"); + Objects.requireNonNull(contractId, "contractId must not be null"); + Objects.requireNonNull(contractName, "contractName must not be null"); + Objects.requireNonNull(files, "files must not be null"); + + final ContractVerificationState state = checkVerification(contractId); + if (state != ContractVerificationState.NONE) { + throw new IllegalStateException("Contract is already verified"); + } + + final JsonObject requestBody = Json.createObjectBuilder() + .add("address", contractId.toSolidityAddress()) + .add("chain", getChainId()) + .add("creatorTxHash", "") + .add("chosenContract", "") + .add("files", Json.createObjectBuilder(files).build()) + .build(); + + try { + final Response response = + webClient + .target(CONTRACT_VERIFICATION_URL + "/verify") + .request(MediaType.APPLICATION_JSON) + .post(Entity.json(requestBody.toString())); + if (response.getStatusInfo().getFamily() != Response.Status.Family.SUCCESSFUL) { + handleError(response); + } + final String resultBody = response.readEntity(String.class); + final JsonParser parser = jsonParserFactory.createParser(new StringReader(resultBody)); + final JsonObject root = parser.getObject(); + final JsonArray resultArray = root.getJsonArray("result"); + if (resultArray == null || resultArray.size() != 1) { + throw new RuntimeException("Expected exactly one result"); + } + final String status = resultArray.getJsonObject(0).getString("status"); + return parseStatus(status); + } catch (final Exception e) { + throw new HieroException("Error verification step", e); + } } } diff --git a/pom.xml b/pom.xml index a5388d72..d504a5bb 100644 --- a/pom.xml +++ b/pom.xml @@ -215,6 +215,7 @@ hiero-enterprise-spring hiero-enterprise-microprofile hiero-enterprise-spring-sample + hiero-enterprise-cli-sample hiero-enterprise-microprofile-sample