diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9437c112d..cfd79c6f1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -92,7 +92,7 @@ jobs: run: ./gradlew checkAllModuleInfo --continue --scan - name: Start Local Node - run: npx @hashgraph/hedera-local start -d --network local + run: npx @hashgraph/hedera-local start -d --network local --network-tag=0.57.0 - name: Run Unit and Integration Tests env: @@ -150,7 +150,7 @@ jobs: run: ./gradlew -p example-android assemble --scan - name: Start the local node - run: npx @hashgraph/hedera-local start -d --network local + run: npx @hashgraph/hedera-local start -d --network local --network-tag=0.57.0 - name: Prepare .env for Examples run: | diff --git a/examples/src/main/java/com/hedera/hashgraph/sdk/examples/LongTermScheduledTransactionExample.java b/examples/src/main/java/com/hedera/hashgraph/sdk/examples/LongTermScheduledTransactionExample.java new file mode 100644 index 000000000..7c56bfc6f --- /dev/null +++ b/examples/src/main/java/com/hedera/hashgraph/sdk/examples/LongTermScheduledTransactionExample.java @@ -0,0 +1,254 @@ +/*- + * + * Hedera Java SDK + * + * Copyright (C) 2020 - 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package com.hedera.hashgraph.sdk.examples; + +import com.hedera.hashgraph.sdk.AccountBalanceQuery; +import com.hedera.hashgraph.sdk.AccountCreateTransaction; +import com.hedera.hashgraph.sdk.AccountId; +import com.hedera.hashgraph.sdk.AccountUpdateTransaction; +import com.hedera.hashgraph.sdk.Client; +import com.hedera.hashgraph.sdk.Hbar; +import com.hedera.hashgraph.sdk.KeyList; +import com.hedera.hashgraph.sdk.PrivateKey; +import com.hedera.hashgraph.sdk.ScheduleInfo; +import com.hedera.hashgraph.sdk.ScheduleInfoQuery; +import com.hedera.hashgraph.sdk.ScheduleSignTransaction; +import com.hedera.hashgraph.sdk.TransferTransaction; +import com.hedera.hashgraph.sdk.logger.LogLevel; +import com.hedera.hashgraph.sdk.logger.Logger; +import io.github.cdimascio.dotenv.Dotenv; +import java.time.Instant; +import java.util.Objects; + +/** + * How to long term schedule transactions (HIP-423). + */ +class LongTermScheduledTransactionExample { + + /* + * See .env.sample in the examples folder root for how to specify values below + * or set environment variables with the same names. + */ + + /** + * Operator's account ID. Used to sign and pay for operations on Hedera. + */ + private static final AccountId OPERATOR_ID = AccountId.fromString( + Objects.requireNonNull(Dotenv.load().get("OPERATOR_ID"))); + + /** + * Operator's private key. + */ + private static final PrivateKey OPERATOR_KEY = PrivateKey.fromString( + Objects.requireNonNull(Dotenv.load().get("OPERATOR_KEY"))); + + /** + * HEDERA_NETWORK defaults to testnet if not specified in dotenv file. Network can be: localhost, testnet, + * previewnet or mainnet. + */ + private static final String HEDERA_NETWORK = Dotenv.load().get("HEDERA_NETWORK", "testnet"); + + /** + * SDK_LOG_LEVEL defaults to SILENT if not specified in dotenv file. Log levels can be: TRACE, DEBUG, INFO, WARN, + * ERROR, SILENT. + *

+ * Important pre-requisite: set simple logger log level to same level as the SDK_LOG_LEVEL, for example via VM + * options: -Dorg.slf4j.simpleLogger.log.com.hedera.hashgraph=trace + */ + private static final String SDK_LOG_LEVEL = Dotenv.load().get("SDK_LOG_LEVEL", "SILENT"); + + public static void main(String[] args) throws Exception { + System.out.println("Long Term Scheduled Transaction Example Start!"); + + /* + * Step 0: + * Create and configure the SDK Client. + */ + Client client = ClientHelper.forName(HEDERA_NETWORK); + // All generated transactions will be paid by this account and signed by this key. + client.setOperator(OPERATOR_ID, OPERATOR_KEY); + // Attach logger to the SDK Client. + client.setLogger(new Logger(LogLevel.valueOf(SDK_LOG_LEVEL))); + + /* + * Step 1: + * Create key pairs + */ + var privateKey1 = PrivateKey.generateED25519(); + var publicKey1 = privateKey1.getPublicKey(); + var privateKey2 = PrivateKey.generateED25519(); + + System.out.println("Creating a Key List..." + + "(with threshold, it will require 2 of 2 keys we generated to sign on anything modifying this account)."); + KeyList thresholdKey = KeyList.withThreshold(2); + thresholdKey.add(privateKey1); + thresholdKey.add(privateKey2); + System.out.println("Created a Key List: " + thresholdKey); + + /* + * Step 2: + * Create the account + */ + System.out.println("Creating new account...(with the above Key List as an account key)."); + var alice = new AccountCreateTransaction() + .setKey(thresholdKey) + .setInitialBalance(new Hbar(2)) + .execute(client) + .getReceipt(client).accountId; + System.out.println("Created new account with ID: " + alice); + + /* + * Step 3: + * Schedule a transfer transaction of 1 Hbar from the created account to the + * operator account with an expirationTime of + * 24 hours in the future and waitForExpiry=false + */ + System.out.println("Creating new scheduled transaction with 1 day expiry"); + TransferTransaction transfer = new TransferTransaction() + .addHbarTransfer(alice, new Hbar(1).negated()) + .addHbarTransfer(client.getOperatorAccountId(), new Hbar(1)); + + int oneDayInSecs = 86400; + var scheduleId = transfer + .schedule() + .setWaitForExpiry(false) + .setExpirationTime(Instant.now().plusSeconds(oneDayInSecs)) + .execute(client) + .getReceipt(client) + .scheduleId; + + /* + * Step 4: + * Sign the transaction with one key and verify the transaction is not executed + */ + System.out.println("Signing the new scheduled transaction with 1 key"); + new ScheduleSignTransaction() + .setScheduleId(scheduleId) + .freezeWith(client) + .sign(privateKey1) + .execute(client) + .getReceipt(client); + + ScheduleInfo info = new ScheduleInfoQuery() + .setScheduleId(scheduleId) + .execute(client); + System.out.println("Scheduled transaction is not yet executed. Executed at: " + info.executedAt); + + /* + * Step 5: + * Sign the transaction with the other key and verify the transaction executes successfully + */ + var accountBalance = new AccountBalanceQuery() + .setAccountId(alice) + .execute(client); + System.out.println("Alice's account balance before schedule transfer: " + accountBalance.hbars); + + System.out.println("Signing the new scheduled transaction with the 2nd key"); + new ScheduleSignTransaction() + .setScheduleId(scheduleId) + .freezeWith(client) + .sign(privateKey2) + .execute(client) + .getReceipt(client); + + accountBalance = new AccountBalanceQuery() + .setAccountId(alice) + .execute(client); + System.out.println("Alice's account balance after schedule transfer: " + accountBalance.hbars); + + info = new ScheduleInfoQuery() + .setScheduleId(scheduleId) + .execute(client); + System.out.println("Scheduled transaction is executed. Executed at: " + info.executedAt); + + /* + * Step 6: + * Schedule another transfer transaction of 1 Hbar from the account to the operator account + * with an expirationTime of 10 seconds in the future and waitForExpiry=true . + */ + System.out.println("Creating new scheduled transaction with 10 seconds expiry"); + transfer = new TransferTransaction() + .addHbarTransfer(alice, new Hbar(1).negated()) + .addHbarTransfer(client.getOperatorAccountId(), new Hbar(1)); + + var scheduleId2 = transfer + .schedule() + .setWaitForExpiry(true) + .setExpirationTime(Instant.now().plusSeconds(10)) + .execute(client) + .getReceipt(client) + .scheduleId; + long startTime = System.currentTimeMillis(); + long elapsedTime = 0; + + /* + * Step 7: + * Sign the transaction with one key and verify the transaction is not executed + */ + System.out.println("Signing the new scheduled transaction with 1 key"); + new ScheduleSignTransaction() + .setScheduleId(scheduleId2) + .freezeWith(client) + .sign(privateKey1) + .execute(client) + .getReceipt(client); + + info = new ScheduleInfoQuery() + .setScheduleId(scheduleId2) + .execute(client); + System.out.println("Scheduled transaction is not yet executed. Executed at: " + info.executedAt); + + /* + * Step 8: + * Update the account’s key to be only the one key + * that has already signed the scheduled transfer. + */ + System.out.println("Updating Alice's key to be the 1st key"); + new AccountUpdateTransaction() + .setAccountId(alice) + .setKey(publicKey1) + .freezeWith(client) + .sign(privateKey1) + .sign(privateKey2) + .execute(client) + .getReceipt(client); + + /* + * Step 9: + * Verify that the transfer successfully executes roughly at the time of its expiration. + */ + accountBalance = new AccountBalanceQuery() + .setAccountId(alice) + .execute(client); + + System.out.println("Alice's account balance before schedule transfer: " + accountBalance.hbars); + while (elapsedTime < 10 * 1000) { + elapsedTime = System.currentTimeMillis() - startTime; + System.out.printf("Elapsed time: %.1f seconds\r", elapsedTime / 1000.0); + Thread.sleep(100); // Pause briefly to reduce CPU usage + } + accountBalance = new AccountBalanceQuery() + .setAccountId(alice) + .execute(client); + System.out.println("Alice's account balance after schedule transfer: " + accountBalance.hbars); + + System.out.println("Long Term Scheduled Transaction Example Complete!"); + } +} diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/RequestType.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/RequestType.java index c532e1eca..d528b5d7d 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/RequestType.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/RequestType.java @@ -442,7 +442,12 @@ public enum RequestType { /** * Submit a vote as part of the Threshold Signature Scheme (TSS) processing. */ - TSS_VOTE(HederaFunctionality.TssVote); + TSS_VOTE(HederaFunctionality.TssVote), + + /** + * Submit a node signature as part of the Threshold Signature Scheme (TSS) processing. + */ + TSS_SHARE_SIGNATURE(HederaFunctionality.TssShareSignature); final HederaFunctionality code; @@ -536,6 +541,7 @@ static RequestType valueOf(HederaFunctionality code) { case TokenClaimAirdrop -> TOKEN_CLAIM_AIRDROP; case TssMessage -> TSS_MESSAGE; case TssVote -> TSS_VOTE; + case TssShareSignature -> TSS_SHARE_SIGNATURE; default -> throw new IllegalStateException("(BUG) unhandled HederaFunctionality"); }; } @@ -627,6 +633,7 @@ public String toString() { case TOKEN_CLAIM_AIRDROP -> "TOKEN_CLAIM_AIRDROP"; case TSS_MESSAGE -> "TSS_MESSAGE"; case TSS_VOTE -> "TSS_VOTE"; + case TSS_SHARE_SIGNATURE -> "TSS_SHARE_SIGNATURE"; }; } } diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/Status.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/Status.java index b0fc94041..4290ab9bf 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/Status.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/Status.java @@ -1749,7 +1749,25 @@ public enum Status { * The client SHOULD query mirror node to determine the status of the pending * airdrop and whether the sender can fulfill the offer. */ - INVALID_TOKEN_IN_PENDING_AIRDROP(ResponseCodeEnum.INVALID_TOKEN_IN_PENDING_AIRDROP); + INVALID_TOKEN_IN_PENDING_AIRDROP(ResponseCodeEnum.INVALID_TOKEN_IN_PENDING_AIRDROP), + + /** + * A scheduled transaction configured to wait for expiry to execute was given + * an expiry time at which there is already too many transactions scheduled to + * expire; its creation must be retried with a different expiry. + */ + SCHEDULE_EXPIRY_IS_BUSY(ResponseCodeEnum.SCHEDULE_EXPIRY_IS_BUSY), + + /** + * The provided gRPC certificate hash is invalid. + */ + INVALID_GRPC_CERTIFICATE_HASH(ResponseCodeEnum.INVALID_GRPC_CERTIFICATE_HASH), + + /** + * A scheduled transaction configured to wait for expiry to execute was not + * given an explicit expiration time. + */ + MISSING_EXPIRY_TIME(ResponseCodeEnum.MISSING_EXPIRY_TIME); final ResponseCodeEnum code; @@ -2087,6 +2105,9 @@ static Status valueOf(ResponseCodeEnum code) { case INVALID_PENDING_AIRDROP_ID -> INVALID_PENDING_AIRDROP_ID; case TOKEN_AIRDROP_WITH_FALLBACK_ROYALTY -> TOKEN_AIRDROP_WITH_FALLBACK_ROYALTY; case INVALID_TOKEN_IN_PENDING_AIRDROP -> INVALID_TOKEN_IN_PENDING_AIRDROP; + case SCHEDULE_EXPIRY_IS_BUSY -> SCHEDULE_EXPIRY_IS_BUSY; + case INVALID_GRPC_CERTIFICATE_HASH -> INVALID_GRPC_CERTIFICATE_HASH; + case MISSING_EXPIRY_TIME -> MISSING_EXPIRY_TIME; case UNRECOGNIZED -> // NOTE: Protobuf deserialization will not give us the code on the wire throw new IllegalArgumentException( diff --git a/sdk/src/main/proto/basic_types.proto b/sdk/src/main/proto/basic_types.proto index 7ce6755b4..282890f95 100644 --- a/sdk/src/main/proto/basic_types.proto +++ b/sdk/src/main/proto/basic_types.proto @@ -1246,7 +1246,7 @@ enum HederaFunctionality { TokenCancelAirdrop = 94; /** - * Claim one or more pending airdrops + * Claim one or more pending airdrops */ TokenClaimAirdrop = 95; @@ -1259,6 +1259,11 @@ enum HederaFunctionality { * Submit a vote as part of the Threshold Signature Scheme (TSS) processing. */ TssVote = 97; + + /** + * Submit a node signature as part of the Threshold Signature Scheme (TSS) processing. + */ + TssShareSignature = 98; } /** diff --git a/sdk/src/main/proto/block_stream_info.proto b/sdk/src/main/proto/block_stream_info.proto index b0f5f41d1..cf3f36809 100644 --- a/sdk/src/main/proto/block_stream_info.proto +++ b/sdk/src/main/proto/block_stream_info.proto @@ -88,7 +88,7 @@ message BlockStreamInfo { * The latest available hash SHALL be for block N-1.
* This is REQUIRED to implement the EVM `BLOCKHASH` opcode. *

- *

Field Length
+ * ### Field Length * Each hash value SHALL be the trailing 265 bits of a SHA2-384 hash.
* The length of this field SHALL be an integer multiple of 32 bytes.
* This field SHALL be at least 32 bytes.
@@ -143,9 +143,9 @@ message BlockStreamInfo { /** * A version describing the version of application software. - *

- * This SHALL be the software version that created this block. - */ + *

+ * This SHALL be the software version that created this block. + */ proto.SemanticVersion creation_software_version = 11; /** @@ -155,4 +155,12 @@ message BlockStreamInfo { * at which an interval of time-dependent events were processed. */ proto.Timestamp last_interval_process_time = 12; + + /** + * The time stamp at which the last user transaction was handled. + *

+ * This field SHALL hold the consensus time for the last time + * at which a user transaction was handled. + */ + proto.Timestamp last_handle_time = 13; } diff --git a/sdk/src/main/proto/ledger_id.proto b/sdk/src/main/proto/ledger_id.proto index 83e1b5478..98e5d42f4 100644 --- a/sdk/src/main/proto/ledger_id.proto +++ b/sdk/src/main/proto/ledger_id.proto @@ -83,7 +83,7 @@ message LedgerId { /** * A signature from the prior ledger key.
* This signature is the _previous_ ledger ID signing _this_ ledger ID.
- * This value MAY be unset, if there is no prior ledger ID.
+ * This value MAY be unset, if there is no prior ledger ID.
* This value SHOULD be set if a prior ledger ID exists * to generate the signature. */ diff --git a/sdk/src/main/proto/node_delete.proto b/sdk/src/main/proto/node_delete.proto index 9bbdc494c..48395312c 100644 --- a/sdk/src/main/proto/node_delete.proto +++ b/sdk/src/main/proto/node_delete.proto @@ -25,9 +25,9 @@ option java_multiple_files = true; /** * A transaction body to delete a node from the network address book. * - * This transaction body SHALL be considered a "privileged transaction". - * - * - A `NodeDeleteTransactionBody` MUST be signed by the governing council. + * - A `NodeDeleteTransactionBody` MUST be signed by one of those keys: + * adminKey, treasure account (2) key, systemAdmin(50) key, or + * addressBookAdmin(55) key. * - Upon success, the address book entry SHALL enter a "pending delete" * state. * - All address book entries pending deletion SHALL be removed from the @@ -36,9 +36,8 @@ option java_multiple_files = true; * - A deleted address book node SHALL be removed entirely from network state. * - A deleted address book node identifier SHALL NOT be reused. * - * ### Record Stream Effects - * Upon completion the "deleted" `node_id` SHALL be in the transaction - * receipt. + * ### Block Stream Effects + * None. */ message NodeDeleteTransactionBody { /** diff --git a/sdk/src/main/proto/node_update.proto b/sdk/src/main/proto/node_update.proto index a8b84e7ab..25eed414c 100644 --- a/sdk/src/main/proto/node_update.proto +++ b/sdk/src/main/proto/node_update.proto @@ -41,9 +41,8 @@ import "basic_types.proto"; * configuration during the next `freeze` transaction with the field * `freeze_type` set to `PREPARE_UPGRADE`. * - * ### Record Stream Effects - * Upon completion the `node_id` for the updated entry SHALL be in the - * transaction receipt. + * ### Block Stream Effects + * None. */ message NodeUpdateTransactionBody { /** @@ -91,9 +90,7 @@ message NodeUpdateTransactionBody { * details.
*

Example
* Hedera Mainnet _requires_ that address be specified, and does not - * permit DNS name (FQDN) to be specified.
- * Mainnet also requires that the first entry be an "internal" IP - * address and the second entry be an "external" IP address. + * permit DNS name (FQDN) to be specified. *
*
* Solo, however, _requires_ DNS name (FQDN) but also permits diff --git a/sdk/src/main/proto/recordcache.proto b/sdk/src/main/proto/recordcache.proto index 93b61f4be..5398675d5 100644 --- a/sdk/src/main/proto/recordcache.proto +++ b/sdk/src/main/proto/recordcache.proto @@ -1,13 +1,22 @@ +/** + * # Record Cache + * The Record Cache holds transaction records for a short time, and is the + * source for responses to `transactionGetRecord` and `transactionGetReceipt` + * queries. + * + * ### Keywords + * The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", + * "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this + * document are to be interpreted as described in [RFC2119](https://www.ietf.org/rfc/rfc2119) + * and clarified in [RFC8174](https://www.ietf.org/rfc/rfc8174). + */ syntax = "proto3"; package proto; -/*- - * ‌ - * Hedera Network Services Protobuf - * ​ - * Copyright (C) 2018 - 2023 Hedera Hashgraph, LLC - * ​ +/* + * Copyright (C) 2023-2024 Hedera Hashgraph, LLC + * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -19,7 +28,6 @@ package proto; * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. - * ‍ */ import "basic_types.proto"; @@ -31,57 +39,83 @@ option java_package = "com.hedera.hashgraph.sdk.proto"; option java_multiple_files = true; /** - * As transactions are handled and records and receipts are created, they are stored in state for a configured time - * limit (perhaps, for example, 3 minutes). During this time window, any client can query the node and get the record - * or receipt for the transaction. The TransactionRecordEntry is the object stored in state with this information. + * As transactions are handled and records and receipts are created, they are + * stored in state for a configured time period (for example, 3 minutes). + * During this time, any client can query the node and get the record or receipt + * for the transaction. The `TransactionRecordEntry` is the object stored in + * state with this information. */ message TransactionRecordEntry { /** - * The ID of the node that submitted the transaction to consensus. The ID is the ID of the node as known by the - * address book. Valid node IDs are in the range 0..2^63-1, inclusive. + * A node identifier.
+ * This identifier is the node, as known to the address book, that + * submitted the transaction for consensus. + *

+ * This SHALL be a whole number. */ int64 node_id = 1; /** - * The AccountID of the payer of the transaction. This may be the same as the account ID within the Transaction ID - * of the record, or it may be the account ID of the node that submitted the transaction to consensus if the account - * ID in the Transaction ID is not able to pay. + * An Account identifier for the payer for the transaction. + *

+ * This MAY be the same as the account ID within the Transaction ID of the + * record, or it MAY be the account ID of the node that submitted the + * transaction to consensus if the account ID in the Transaction ID was + * not able to pay. */ AccountID payer_account_id = 2; /** - * The transaction record for the transaction. + * A transaction record for the transaction. */ TransactionRecord transaction_record = 3; } + /** - * As a single transaction is handled a receipt is created. It is stored in state for a configured time - * limit (perhaps, for example, 3 minutes). During this time window, any client can query the node and get the - * receipt for the transaction. The TransactionReceiptEntry is the object stored in state with this information. + * An entry in the record cache with the receipt for a transaction. + * This is the entry stored in state that enables returning the receipt + * information when queried by clients. + * + * When a transaction is handled a receipt SHALL be created.
+ * This receipt MUST be stored in state for a configured time limit + * (e.g. 3 minutes).
+ * While a receipt is stored, a client MAY query the node and retrieve + * the receipt. */ message TransactionReceiptEntry { /** - * The ID of the node that submitted the transaction to consensus. The ID is the ID of the node as known by the - * address book. Valid node IDs are in the range 0..2^63-1, inclusive. - */ + * A node identifier.
+ * This identifies the node that submitted the transaction to consensus. + * The value is the identifier as known to the current address book. + *

+ * Valid node identifiers SHALL be between 0 and 263-1, + * inclusive. + */ uint64 node_id = 1; /** - * The id of the submitted transaction. + * A transaction identifier.
+ * This identifies the submitted transaction for this receipt. */ TransactionID transaction_id = 2; /** - * The resulting status of handling the transaction. + * A status result.
+ * This is the final status after handling the transaction. */ ResponseCodeEnum status = 3; } + /** - * As transactions are handled and receipts are created, they are stored in state for a configured time - * limit (perhaps, for example, 3 minutes). During this time window, any client can query the node and get the - * receipt for the transaction. The TransactionReceiptEntries is the object stored in state with this information. - * This object contains a list of TransactionReceiptEntry objects. + * A cache of transaction receipts.
+ * As transactions are handled and receipts are created, they are stored in + * state for a configured time limit (perhaps, for example, 3 minutes). + * During this time window, any client can query the node and get the receipt + * for the transaction. The `TransactionReceiptEntries` is the object stored in + * state with this information. + * + * This message SHALL contain a list of `TransactionReceiptEntry` objects. */ message TransactionReceiptEntries { repeated TransactionReceiptEntry entries = 1; -} \ No newline at end of file +} diff --git a/sdk/src/main/proto/response_code.proto b/sdk/src/main/proto/response_code.proto index 752a012f7..81a6bc473 100644 --- a/sdk/src/main/proto/response_code.proto +++ b/sdk/src/main/proto/response_code.proto @@ -1593,4 +1593,22 @@ enum ResponseCodeEnum { * airdrop and whether the sender can fulfill the offer. */ INVALID_TOKEN_IN_PENDING_AIRDROP = 369; + + /** + * A scheduled transaction configured to wait for expiry to execute was given + * an expiry time at which there is already too many transactions scheduled to + * expire; its creation must be retried with a different expiry. + */ + SCHEDULE_EXPIRY_IS_BUSY = 370; + + /** + * The provided gRPC certificate hash is invalid. + */ + INVALID_GRPC_CERTIFICATE_HASH = 371; + + /** + * A scheduled transaction configured to wait for expiry to execute was not + * given an explicit expiration time. + */ + MISSING_EXPIRY_TIME = 372; } diff --git a/sdk/src/main/proto/roster.proto b/sdk/src/main/proto/roster.proto index f160e927d..853856c30 100644 --- a/sdk/src/main/proto/roster.proto +++ b/sdk/src/main/proto/roster.proto @@ -31,13 +31,13 @@ option java_multiple_files = true; */ message Roster { - /** - * List of roster entries, one per consensus node. - *

- * This list SHALL contain roster entries in natural order of ascending node ids. - * This list SHALL NOT be empty.
- */ - repeated RosterEntry roster_entries = 1; + /** + * List of roster entries, one per consensus node. + *

+ * This list SHALL contain roster entries in natural order of ascending node ids. + * This list SHALL NOT be empty.
+ */ + repeated RosterEntry roster_entries = 1; } /** @@ -45,63 +45,54 @@ message Roster { * * Each roster entry SHALL encapsulate the elements required * to manage node participation in the Threshold Signature Scheme (TSS).
- * All fields except tss_encryption_key are REQUIRED. + * All fields are REQUIRED. */ message RosterEntry { - /** - * A consensus node identifier. - *

- * Node identifiers SHALL be unique _within_ a ledger, - * and MUST NOT be repeated _between_ shards and realms. - */ - uint64 node_id = 1; + // The tssEncryptionKey field has been reserved because it is no longer + // required to be stored in the Roster. The public portion of this key will + // continue to be stored in TssBaseService, but will, however, just be + // stored as raw bytes. + reserved 4; - /** - * A consensus weight. - *

- * Each node SHALL have a weight of zero or more in consensus calculations.
- * The sum of the weights of all nodes in the roster SHALL form the total weight of the system, - * and each node's individual weight SHALL be proportional to that sum.
- */ - uint64 weight = 2; + /** + * A consensus node identifier. + *

+ * Node identifiers SHALL be unique _within_ a ledger, + * and MUST NOT be repeated _between_ shards and realms. + */ + uint64 node_id = 1; - /** - * An RSA public certificate used for signing gossip events. - *

- * This value SHALL be a certificate of a type permitted for gossip - * signatures.
- * This value SHALL be the DER encoding of the certificate presented.
- * This field is REQUIRED and MUST NOT be empty. - */ - bytes gossip_ca_certificate = 3; + /** + * A consensus weight. + *

+ * Each node SHALL have a weight of zero or more in consensus calculations.
+ * The sum of the weights of all nodes in the roster SHALL form the total weight of the system, + * and each node's individual weight SHALL be proportional to that sum.
+ */ + uint64 weight = 2; - /** - * An elliptic curve public encryption key.
- * This is currently an ALT_BN128 curve, but the elliptic curve - * type may change in the future. For example, - * if the Ethereum ecosystem creates precompiles for BLS12_381, - * we may switch to that curve. - *

- * This value SHALL be specified according to EIP-196 and EIP-197 standards, - * See EIP-196 and - * EIP-197
- * This field is _initially_ OPTIONAL (i.e. it can be unset _when created_) - * but once set, it is REQUIRED thereafter. - */ - bytes tss_encryption_key = 4; + /** + * An RSA public certificate used for signing gossip events. + *

+ * This value SHALL be a certificate of a type permitted for gossip + * signatures.
+ * This value SHALL be the DER encoding of the certificate presented.
+ * This field is REQUIRED and MUST NOT be empty. + */ + bytes gossip_ca_certificate = 3; - /** - * A list of service endpoints for gossip. - *

- * These endpoints SHALL represent the published endpoints to which other - * consensus nodes may _gossip_ transactions.
- * If the network configuration value `gossipFqdnRestricted` is set, then - * all endpoints in this list SHALL supply only IP address.
- * If the network configuration value `gossipFqdnRestricted` is _not_ set, - * then endpoints in this list MAY supply either IP address or FQDN, but - * SHALL NOT supply both values for the same endpoint.
- * This list SHALL NOT be empty.
- */ - repeated proto.ServiceEndpoint gossip_endpoint = 5; + /** + * A list of service endpoints for gossip. + *

+ * These endpoints SHALL represent the published endpoints to which other + * consensus nodes may _gossip_ transactions.
+ * If the network configuration value `gossipFqdnRestricted` is set, then + * all endpoints in this list SHALL supply only IP address.
+ * If the network configuration value `gossipFqdnRestricted` is _not_ set, + * then endpoints in this list MAY supply either IP address or FQDN, but + * SHALL NOT supply both values for the same endpoint.
+ * This list SHALL NOT be empty.
+ */ + repeated proto.ServiceEndpoint gossip_endpoint = 5; } diff --git a/sdk/src/main/proto/schedule.proto b/sdk/src/main/proto/schedule.proto index ea997e04b..b8abb6c9e 100644 --- a/sdk/src/main/proto/schedule.proto +++ b/sdk/src/main/proto/schedule.proto @@ -146,3 +146,47 @@ message ScheduleList { */ repeated Schedule schedules = 1; } + +/** + * A message for storing a list of schedule identifiers in state.
+ * This is used to store lists of `ScheduleID` values. + * One example is all schedules that expire at a particular time. + */ +message ScheduleIdList { + /** + * A list of schedule identifiers, in no particular order. + *

+ * While the order is not _specified_, it MUST be deterministic. + */ + repeated ScheduleID schedule_ids = 1; +} + +/** + * The value of a map summarizing the counts of scheduled and processed transactions + * within a particular consensus second. + */ +message ScheduledCounts { + /** + * The number of transactions scheduled to expire at a consensus second. + */ + uint32 number_scheduled = 1; + /** + * The number of scheduled transactions that have been processed at a consensus second. + */ + uint32 number_processed = 2; +} + +/** + * A key mapping to a particular ScheduleID that will execute at a given order number + * within a given consensus second. + */ +message ScheduledOrder { + /** + * The consensus second in which the transaction is expired. + */ + uint64 expiry_second = 1; + /* + * The ordered position within the consensus second that the transaction will be executed. + */ + uint32 order_number = 2; +} diff --git a/sdk/src/main/proto/token_get_info.proto b/sdk/src/main/proto/token_get_info.proto index 3845ff78a..9eb7bd7c9 100644 --- a/sdk/src/main/proto/token_get_info.proto +++ b/sdk/src/main/proto/token_get_info.proto @@ -209,6 +209,36 @@ message TokenInfo { * (token definition and individual NFTs). */ Key metadata_key = 28; + + /** + * A function-specific account key.
+ * This key authorizes transactions to lock tokens in an account or account + * partition. + *

+ * The key SHALL be used to authorize the locking and unlocking of tokens, + * or the transfer of locked tokens on balances held by the user + * on their account, or on the partition in their account. + */ + Key lock_key = 29; + + /** + * A function-specific account key.
+ * This key authorizes transactions to partition fungible tokens and NFTs + * held by an account. + *

+ * This key SHALL be used to authorize the creation, deletion, or updating + * of partition definitions owned by the token definition. + */ + Key partition_key = 30; + + /** + * A function-specific account key.
+ * This key authorizes transactions to move tokens between partitions. + *

+ * This key SHALL be used to authorize the movement of tokens between + * partitions. + */ + Key partition_move_key = 31; } /** diff --git a/sdk/src/main/proto/transaction_body.proto b/sdk/src/main/proto/transaction_body.proto index 2fab1d530..24f4eb3e5 100644 --- a/sdk/src/main/proto/transaction_body.proto +++ b/sdk/src/main/proto/transaction_body.proto @@ -91,6 +91,7 @@ import "node_delete.proto"; import "tss_message.proto"; import "tss_vote.proto"; +import "tss_share_signature.proto"; /** * A single transaction. All transaction types are possible here. @@ -431,5 +432,10 @@ message TransactionBody { * A transaction body for a `tssVote` request. */ com.hedera.hapi.services.auxiliary.tss.TssVoteTransactionBody tssVote = 62; + + /** + * A transaction body for a 'tssShareSignature` request + */ + com.hedera.hapi.services.auxiliary.tss.TssShareSignatureTransactionBody tssShareSignature = 63; } } diff --git a/sdk/src/main/proto/transaction_receipt.proto b/sdk/src/main/proto/transaction_receipt.proto index e744c2a76..156ec6434 100644 --- a/sdk/src/main/proto/transaction_receipt.proto +++ b/sdk/src/main/proto/transaction_receipt.proto @@ -168,11 +168,10 @@ message TransactionReceipt { repeated int64 serialNumbers = 14; /** - * In the receipt of a NodeCreate, NodeUpdate, NodeDelete, the id of the newly created node. * An affected node identifier.
+ * In the receipt of a NodeCreate, the id of the newly created node. + *

* This value SHALL be set following a `createNode` transaction.
- * This value SHALL be set following a `updateNode` transaction.
- * This value SHALL be set following a `deleteNode` transaction.
* This value SHALL NOT be set following any other transaction. */ uint64 node_id = 15; diff --git a/sdk/src/main/proto/tss_message.proto b/sdk/src/main/proto/tss_message.proto index 921652ba1..73c495069 100644 --- a/sdk/src/main/proto/tss_message.proto +++ b/sdk/src/main/proto/tss_message.proto @@ -48,7 +48,7 @@ option java_multiple_files = true; message TssMessageTransactionBody { /** - * A hash of the roster containing the node generating the TssMessage.
+ * A hash of the roster containing the node generating the TssMessage.
* This hash uniquely identifies the source roster, which will include * an entry for the node generating this TssMessage. *

diff --git a/sdk/src/main/proto/tss_share_signature.proto b/sdk/src/main/proto/tss_share_signature.proto new file mode 100644 index 000000000..8ff4b14f1 --- /dev/null +++ b/sdk/src/main/proto/tss_share_signature.proto @@ -0,0 +1,88 @@ +/** + * # Tss Share Signature + * Represents a transaction that submits a node's share signature on a block hash + * during the TSS (Threshold Signature Scheme) process. + * + * ### Keywords + * The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", + * "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this + * document are to be interpreted as described in + * [RFC2119](https://www.ietf.org/rfc/rfc2119) and clarified in + * [RFC8174](https://www.ietf.org/rfc/rfc8174). + */ +syntax = "proto3"; + +package com.hedera.hapi.services.auxiliary.tss; + +/* + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +option java_package = "com.hedera.hapi.services.auxiliary.tss.legacy"; +// <<>> This comment is special code for setting PBJ Compiler java package +option java_multiple_files = true; + +/** + * A TSS Share Signature transaction Body.
+ * This transaction body communicates a node's signature of a block hash + * using its private share within the TSS process. + * This transaction MUST be prioritized for low latency gossip transmission. + * + * ### Block Stream Effects + * This transaction body will be present in the block stream. This will not have + * any state changes or transaction output or transaction result. + */ +message TssShareSignatureTransactionBody { + /** + * A SHA2-384 Hash.
+ * This is the hash of the roster that includes the node whose + * share produced this share signature. + *

+ * This value is REQUIRED.
+ * This value MUST identify the network roster active at the time this + * share signature was produced.
+ * This share signature MUST be produced from a share distributed during + * the re-keying process for the identified roster. + */ + bytes roster_hash = 1; + + /** + * An index of the share from the node private shares.
+ * This is the index of the share that produced this share signature. + *

+ * This value is REQUIRED.
+ * The share referred to by this index MUST exist.
+ * The share index MUST be greater than or equal to 0. + */ + uint64 share_index = 2; + + /** + * A SHA2-384 hash.
+ * This is the hash of the message that was signed. + *

+ * This value is REQUIRED.
+ * The message signed MUST be a block hash. + */ + bytes message_hash = 3; + + /** + * The signature bytes.
+ * This is the signature generated by signing the block hash with the node's private share. + *

+ * This value is REQUIRED.
+ * This value MUST be a valid signature of the message hash with the node's private share. + */ + bytes share_signature = 4; +} diff --git a/sdk/src/main/proto/tss_vote_map_key.proto b/sdk/src/main/proto/tss_vote_map_key.proto index 6ea669c73..d5a0d6692 100644 --- a/sdk/src/main/proto/tss_vote_map_key.proto +++ b/sdk/src/main/proto/tss_vote_map_key.proto @@ -32,27 +32,27 @@ option java_package = "com.hedera.hapi.node.state.tss.legacy"; // <<>> This comment is special code for setting PBJ Compiler java package option java_multiple_files = true; - /** - * A key for use in the Threshold Signature Scheme (TSS) TssVoteMaps. - * - * This key SHALL be used to uniquely identify entries in the Vote Maps. - */ +/** + * A key for use in the Threshold Signature Scheme (TSS) TssVoteMaps. + * + * This key SHALL be used to uniquely identify entries in the Vote Maps. + */ message TssVoteMapKey { - /** - * A hash of the target roster for the associated value in the map.
- * This hash uniquely identifies the target roster. - *

- * This value MUST be set.
- * This value MUST contain a valid hash. - */ - bytes roster_hash = 1; + /** + * A hash of the target roster for the associated value in the map.
+ * This hash uniquely identifies the target roster. + *

+ * This value MUST be set.
+ * This value MUST contain a valid hash. + */ + bytes roster_hash = 1; - /** The node id of the node that created the TssVote.
- * This id uniquely identifies the node. - *

- * This value MUST be set.
- * This value MUST be a valid node id. - */ - uint64 node_id = 2; + /** The node id of the node that created the TssVote.
+ * This id uniquely identifies the node. + *

+ * This value MUST be set.
+ * This value MUST be a valid node id. + */ + uint64 node_id = 2; } diff --git a/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/ScheduleCreateIntegrationTest.java b/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/ScheduleCreateIntegrationTest.java index c871400ba..2f6e483d1 100644 --- a/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/ScheduleCreateIntegrationTest.java +++ b/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/ScheduleCreateIntegrationTest.java @@ -19,10 +19,14 @@ */ package com.hedera.hashgraph.sdk.test.integration; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + import com.hedera.hashgraph.sdk.AccountBalanceQuery; import com.hedera.hashgraph.sdk.AccountCreateTransaction; import com.hedera.hashgraph.sdk.AccountDeleteTransaction; import com.hedera.hashgraph.sdk.AccountId; +import com.hedera.hashgraph.sdk.AccountUpdateTransaction; import com.hedera.hashgraph.sdk.Hbar; import com.hedera.hashgraph.sdk.KeyList; import com.hedera.hashgraph.sdk.PrivateKey; @@ -32,6 +36,7 @@ import com.hedera.hashgraph.sdk.ScheduleInfo; import com.hedera.hashgraph.sdk.ScheduleInfoQuery; import com.hedera.hashgraph.sdk.ScheduleSignTransaction; +import com.hedera.hashgraph.sdk.Status; import com.hedera.hashgraph.sdk.TokenAssociateTransaction; import com.hedera.hashgraph.sdk.TokenCreateTransaction; import com.hedera.hashgraph.sdk.TopicCreateTransaction; @@ -40,19 +45,19 @@ import com.hedera.hashgraph.sdk.TransactionReceipt; import com.hedera.hashgraph.sdk.TransactionResponse; import com.hedera.hashgraph.sdk.TransferTransaction; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Collections; +import java.util.Objects; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import java.time.Instant; -import java.nio.charset.StandardCharsets; -import java.util.Collections; -import java.util.Objects; +class ScheduleCreateIntegrationTest { -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + private final int oneDayInSecs = 86400; -public class ScheduleCreateIntegrationTest { @Test @Disabled @DisplayName("Can create schedule") @@ -179,7 +184,7 @@ void canSignSchedule2() throws Exception { .addHbarTransfer(accountId, new Hbar(1).negated()) .addHbarTransfer(testEnv.operatorId, new Hbar(1)); - // Schedule the transactoin + // Schedule the transaction ScheduleCreateTransaction scheduled = transfer.schedule(); receipt = scheduled.execute(testEnv.client).getReceipt(testEnv.client); @@ -189,8 +194,8 @@ void canSignSchedule2() throws Exception { // Get the schedule info to see if `signatories` is populated with 2/3 signatures ScheduleInfo info = new ScheduleInfoQuery() - .setScheduleId(scheduleId) - .execute(testEnv.client); + .setScheduleId(scheduleId) + .execute(testEnv.client); assertThat(info.executedAt).isNull(); @@ -226,7 +231,7 @@ void canSignSchedule2() throws Exception { @Test @DisplayName("Can schedule token transfer") void canScheduleTokenTransfer() throws Exception { - try(var testEnv = new IntegrationTestEnv(1).useThrowawayAccount()){ + try (var testEnv = new IntegrationTestEnv(1).useThrowawayAccount()) { PrivateKey key = PrivateKey.generateED25519(); @@ -297,7 +302,7 @@ void canScheduleTokenTransfer() throws Exception { @Test @DisplayName("Cannot schedule two identical transactions") void cannotScheduleTwoTransactions() throws Exception { - try(var testEnv = new IntegrationTestEnv(1)){ + try (var testEnv = new IntegrationTestEnv(1)) { var key = PrivateKey.generateED25519(); var accountId = new AccountCreateTransaction() @@ -358,9 +363,9 @@ void canScheduleTopicMessage() throws Exception { keyList.add(key3.getPublicKey()); var response = new AccountCreateTransaction() - .setInitialBalance(new Hbar(100)) - .setKey(keyList) - .execute(testEnv.client); + .setInitialBalance(new Hbar(100)) + .setKey(keyList) + .execute(testEnv.client); assertThat(response.getReceipt(testEnv.client).accountId).isNotNull(); @@ -392,8 +397,8 @@ void canScheduleTopicMessage() throws Exception { // verify schedule has been created and has 1 of 2 signatures var info = new ScheduleInfoQuery() - .setScheduleId(scheduleId) - .execute(testEnv.client); + .setScheduleId(scheduleId) + .execute(testEnv.client); assertThat(info).isNotNull(); assertThat(info.scheduleId).isEqualTo(scheduleId); @@ -420,4 +425,431 @@ void canScheduleTopicMessage() throws Exception { } } + + @Test + @DisplayName("Can sign schedule") + void canSignSchedule() throws Exception { + try (var testEnv = new IntegrationTestEnv(1)) { + + PrivateKey key = PrivateKey.generateED25519(); + + var accountId = new AccountCreateTransaction() + .setKey(key.getPublicKey()) + .setInitialBalance(new Hbar(10)) + .execute(testEnv.client) + .getReceipt(testEnv.client) + .accountId; + + // Create the transaction + TransferTransaction transfer = new TransferTransaction() + .addHbarTransfer(accountId, new Hbar(1).negated()) + .addHbarTransfer(testEnv.operatorId, new Hbar(1)); + + // Schedule the transaction + var scheduleId = transfer + .schedule() + .setExpirationTime(Instant.now().plusSeconds(oneDayInSecs)) + .setScheduleMemo("HIP-423 Integration Test") + .execute(testEnv.client) + .getReceipt(testEnv.client) + .scheduleId; + + ScheduleInfo info = new ScheduleInfoQuery() + .setScheduleId(scheduleId) + .execute(testEnv.client); + + // Verify the transaction is not yet executed + assertThat(info.executedAt).isNull(); + + // Schedule sign + new ScheduleSignTransaction() + .setScheduleId(scheduleId) + .freezeWith(testEnv.client) + .sign(key) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + info = new ScheduleInfoQuery() + .setScheduleId(scheduleId) + .execute(testEnv.client); + + // Verify the transaction is executed + assertThat(info.executedAt).isNotNull(); + + assertThat(scheduleId.getChecksum()).isNull(); + assertThat(scheduleId.hashCode()).isNotZero(); + assertThat(scheduleId.compareTo(ScheduleId.fromBytes(scheduleId.toBytes()))).isZero(); + } + } + + @Test + @DisplayName("Cannot schedule one year into the future") + void cannotScheduleTransactionOneYearIntoTheFuture() throws Exception { + try (var testEnv = new IntegrationTestEnv(1)) { + + PrivateKey key = PrivateKey.generateED25519(); + + var accountId = new AccountCreateTransaction() + .setKey(key.getPublicKey()) + .setInitialBalance(new Hbar(10)) + .execute(testEnv.client) + .getReceipt(testEnv.client) + .accountId; + + // Create the transaction + TransferTransaction transfer = new TransferTransaction() + .addHbarTransfer(accountId, new Hbar(1).negated()) + .addHbarTransfer(testEnv.operatorId, new Hbar(1)); + + // Schedule the transaction + assertThatExceptionOfType(ReceiptStatusException.class).isThrownBy(() -> + transfer + .schedule() + .setExpirationTime(Instant.now().plus(Duration.ofDays(365))) + .setScheduleMemo("HIP-423 Integration Test") + .execute(testEnv.client) + .getReceipt(testEnv.client)) + .withMessageContaining(Status.SCHEDULE_EXPIRATION_TIME_TOO_FAR_IN_FUTURE.toString()); + } + } + + @Test + @DisplayName("Cannot schedule in the past") + void cannotScheduleTransactionInThePast() throws Exception { + try (var testEnv = new IntegrationTestEnv(1)) { + + PrivateKey key = PrivateKey.generateED25519(); + + var accountId = new AccountCreateTransaction() + .setKey(key.getPublicKey()) + .setInitialBalance(new Hbar(10)) + .execute(testEnv.client) + .getReceipt(testEnv.client) + .accountId; + + // Create the transaction + TransferTransaction transfer = new TransferTransaction() + .addHbarTransfer(accountId, new Hbar(1).negated()) + .addHbarTransfer(testEnv.operatorId, new Hbar(1)); + + // Schedule the transaction + assertThatExceptionOfType(ReceiptStatusException.class).isThrownBy(() -> + transfer + .schedule() + .setExpirationTime(Instant.now().minusSeconds(10)) + .setScheduleMemo("HIP-423 Integration Test") + .execute(testEnv.client) + .getReceipt(testEnv.client)) + .withMessageContaining(Status.SCHEDULE_EXPIRATION_TIME_MUST_BE_HIGHER_THAN_CONSENSUS_TIME.toString()); + } + } + + @Test + @DisplayName("Can sign schedule and wait for expiry") + void canSignScheduleAndWaitForExpiry() throws Exception { + try (var testEnv = new IntegrationTestEnv(1)) { + + PrivateKey key = PrivateKey.generateED25519(); + + var accountId = new AccountCreateTransaction() + .setKey(key.getPublicKey()) + .setInitialBalance(new Hbar(10)) + .execute(testEnv.client) + .getReceipt(testEnv.client) + .accountId; + + // Create the transaction + TransferTransaction transfer = new TransferTransaction() + .addHbarTransfer(accountId, new Hbar(1).negated()) + .addHbarTransfer(testEnv.operatorId, new Hbar(1)); + + // Schedule the transaction + var scheduleId = transfer + .schedule() + .setExpirationTime(Instant.now().plusSeconds(oneDayInSecs)) + .setWaitForExpiry(true) + .setScheduleMemo("HIP-423 Integration Test") + .execute(testEnv.client) + .getReceipt(testEnv.client) + .scheduleId; + + ScheduleInfo info = new ScheduleInfoQuery() + .setScheduleId(scheduleId) + .execute(testEnv.client); + + // Verify the transaction is not yet executed + assertThat(info.executedAt).isNull(); + + // Schedule sign + new ScheduleSignTransaction() + .setScheduleId(scheduleId) + .freezeWith(testEnv.client) + .sign(key) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + info = new ScheduleInfoQuery() + .setScheduleId(scheduleId) + .execute(testEnv.client); + + // Verify the transaction is still not executed + assertThat(info.executedAt).isNull(); + + assertThat(scheduleId.getChecksum()).isNull(); + assertThat(scheduleId.hashCode()).isNotZero(); + assertThat(scheduleId.compareTo(ScheduleId.fromBytes(scheduleId.toBytes()))).isZero(); + } + } + + @Test + @DisplayName("Can sign with multisig and update signing requirements") + void canSignWithMultiSigAndUpdateSigningRequirements() throws Exception { + try (var testEnv = new IntegrationTestEnv(1)) { + + PrivateKey key1 = PrivateKey.generateED25519(); + PrivateKey key2 = PrivateKey.generateED25519(); + PrivateKey key3 = PrivateKey.generateED25519(); + PrivateKey key4 = PrivateKey.generateED25519(); + + KeyList keyList = KeyList.withThreshold(2); + + keyList.add(key1.getPublicKey()); + keyList.add(key2.getPublicKey()); + keyList.add(key3.getPublicKey()); + + var accountId = new AccountCreateTransaction() + .setKey(keyList) + .setInitialBalance(new Hbar(10)) + .execute(testEnv.client) + .getReceipt(testEnv.client) + .accountId; + + // Create the transaction + TransferTransaction transfer = new TransferTransaction() + .addHbarTransfer(accountId, new Hbar(1).negated()) + .addHbarTransfer(testEnv.operatorId, new Hbar(1)); + + // Schedule the transaction + var scheduleId = transfer + .schedule() + .setExpirationTime(Instant.now().plusSeconds(oneDayInSecs)) + .setScheduleMemo("HIP-423 Integration Test") + .execute(testEnv.client) + .getReceipt(testEnv.client) + .scheduleId; + + ScheduleInfo info = new ScheduleInfoQuery() + .setScheduleId(scheduleId) + .execute(testEnv.client); + + // Verify the transaction is not executed + assertThat(info.executedAt).isNull(); + + // Sign with one key + new ScheduleSignTransaction() + .setScheduleId(scheduleId) + .freezeWith(testEnv.client) + .sign(key1) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + info = new ScheduleInfoQuery() + .setScheduleId(scheduleId) + .execute(testEnv.client); + + // Verify the transaction is still not executed + assertThat(info.executedAt).isNull(); + + // Update the signing requirements + new AccountUpdateTransaction() + .setAccountId(accountId) + .setKey(key4.getPublicKey()) + .freezeWith(testEnv.client) + .sign(key1) + .sign(key2) + .sign(key4) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + info = new ScheduleInfoQuery() + .setScheduleId(scheduleId) + .execute(testEnv.client); + + // Verify the transaction is still not executed + assertThat(info.executedAt).isNull(); + + // Sign with the updated key + new ScheduleSignTransaction() + .setScheduleId(scheduleId) + .freezeWith(testEnv.client) + .sign(key4) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + info = new ScheduleInfoQuery() + .setScheduleId(scheduleId) + .execute(testEnv.client); + + // Verify the transaction is executed + assertThat(info.executedAt).isNotNull(); + } + } + + @Test + @DisplayName("Can sign with multisig") + void canSignWithMultiSig() throws Exception { + try (var testEnv = new IntegrationTestEnv(1)) { + + PrivateKey key1 = PrivateKey.generateED25519(); + PrivateKey key2 = PrivateKey.generateED25519(); + PrivateKey key3 = PrivateKey.generateED25519(); + + KeyList keyList = KeyList.withThreshold(2); + + keyList.add(key1.getPublicKey()); + keyList.add(key2.getPublicKey()); + keyList.add(key3.getPublicKey()); + + var accountId = new AccountCreateTransaction() + .setKey(keyList) + .setInitialBalance(new Hbar(10)) + .execute(testEnv.client) + .getReceipt(testEnv.client) + .accountId; + + // Create the transaction + TransferTransaction transfer = new TransferTransaction() + .addHbarTransfer(accountId, new Hbar(1).negated()) + .addHbarTransfer(testEnv.operatorId, new Hbar(1)); + + // Schedule the transaction + var scheduleId = transfer + .schedule() + .setExpirationTime(Instant.now().plusSeconds(oneDayInSecs)) + .setScheduleMemo("HIP-423 Integration Test") + .execute(testEnv.client) + .getReceipt(testEnv.client) + .scheduleId; + + ScheduleInfo info = new ScheduleInfoQuery() + .setScheduleId(scheduleId) + .execute(testEnv.client); + + // Verify the transaction is not executed + assertThat(info.executedAt).isNull(); + + // Sign with one key + new ScheduleSignTransaction() + .setScheduleId(scheduleId) + .freezeWith(testEnv.client) + .sign(key1) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + info = new ScheduleInfoQuery() + .setScheduleId(scheduleId) + .execute(testEnv.client); + + // Verify the transaction is still not executed + assertThat(info.executedAt).isNull(); + + // Update the signing requirements + new AccountUpdateTransaction() + .setAccountId(accountId) + .setKey(key1.getPublicKey()) + .freezeWith(testEnv.client) + .sign(key1) + .sign(key2) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + info = new ScheduleInfoQuery() + .setScheduleId(scheduleId) + .execute(testEnv.client); + + // Verify the transaction is still not executed + assertThat(info.executedAt).isNull(); + + // Sign with one more key + new ScheduleSignTransaction() + .setScheduleId(scheduleId) + .freezeWith(testEnv.client) + .sign(key2) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + info = new ScheduleInfoQuery() + .setScheduleId(scheduleId) + .execute(testEnv.client); + + // Verify the transaction is executed + assertThat(info.executedAt).isNotNull(); + } + } + + @Test + @DisplayName("Can execute with short expiration time") + void canExecuteWithShortExpirationTime() throws Exception { + try (var testEnv = new IntegrationTestEnv(1)) { + + PrivateKey key1 = PrivateKey.generateED25519(); + + var accountId = new AccountCreateTransaction() + .setKey(key1) + .setInitialBalance(new Hbar(10)) + .execute(testEnv.client) + .getReceipt(testEnv.client) + .accountId; + + // Create the transaction + TransferTransaction transfer = new TransferTransaction() + .addHbarTransfer(accountId, new Hbar(1).negated()) + .addHbarTransfer(testEnv.operatorId, new Hbar(1)); + + // Schedule the transaction + var scheduleId = transfer + .schedule() + .setExpirationTime(Instant.now().plusSeconds(10)) + .setWaitForExpiry(true) + .setScheduleMemo("HIP-423 Integration Test") + .execute(testEnv.client) + .getReceipt(testEnv.client) + .scheduleId; + + ScheduleInfo info = new ScheduleInfoQuery() + .setScheduleId(scheduleId) + .execute(testEnv.client); + + // Verify the transaction is not executed + assertThat(info.executedAt).isNull(); + + // Sign + new ScheduleSignTransaction() + .setScheduleId(scheduleId) + .freezeWith(testEnv.client) + .sign(key1) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + info = new ScheduleInfoQuery() + .setScheduleId(scheduleId) + .execute(testEnv.client); + + // Verify the transaction is still not executed + assertThat(info.executedAt).isNull(); + + var accountBalanceBefore = new AccountBalanceQuery() + .setAccountId(accountId) + .execute(testEnv.client); + + Thread.sleep(10_000); + + var accountBalanceAfter = new AccountBalanceQuery() + .setAccountId(accountId) + .execute(testEnv.client); + + // Verify the transaction executed after 10 seconds + assertThat(accountBalanceBefore.hbars.compareTo(accountBalanceAfter.hbars)).isEqualTo(1); + } + } }