diff --git a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/services/ServiceScopeLookup.java b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/services/ServiceScopeLookup.java index d49ccd596af5..f1d7a2e5a879 100644 --- a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/services/ServiceScopeLookup.java +++ b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/services/ServiceScopeLookup.java @@ -106,7 +106,7 @@ public String getServiceName(@NonNull final TransactionBody txBody) { TOKEN_CANCEL_AIRDROP, TOKEN_REJECT -> TokenService.NAME; - case UTIL_PRNG -> UtilService.NAME; + case UTIL_PRNG, ATOMIC_BATCH -> UtilService.NAME; case SYSTEM_DELETE -> switch (txBody.systemDeleteOrThrow().id().kind()) { case CONTRACT_ID -> ContractService.NAME; diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/utils/GrpcUtils.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/utils/GrpcUtils.java index bbbdb2d26f84..6647ac2b7cb6 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/utils/GrpcUtils.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/junit/hedera/utils/GrpcUtils.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 Hedera Hashgraph, LLC + * Copyright (C) 2024-2025 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. @@ -220,6 +220,8 @@ public static TransactionResponse submit( .cancelAirdrop(transaction); case TokenClaimAirdrop -> clients.getTokenSvcStub(nodeAccountId, false, false) .claimAirdrop(transaction); + case AtomicBatch -> clients.getUtilSvcStub(nodeAccountId, false, false) + .atomicBatch(transaction); default -> throw new IllegalArgumentException(functionality + " is not a transaction"); }; } diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/HapiSpecOperation.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/HapiSpecOperation.java index ef48ddd07830..d7a595daf88c 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/HapiSpecOperation.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/HapiSpecOperation.java @@ -129,6 +129,7 @@ public abstract class HapiSpecOperation implements SpecOperation { protected Optional controlOverrides = Optional.empty(); protected Map overrides = Collections.EMPTY_MAP; + protected Optional> batchKey = Optional.empty(); protected Optional fee = Optional.empty(); protected List> maxCustomFeeList = new ArrayList<>(); protected Optional validDurationSecs = Optional.empty(); @@ -292,6 +293,7 @@ protected Consumer bodyDef(final HapiSpec spec) { Duration.newBuilder().setSeconds(s).build())); genRecord.ifPresent(builder::setGenerateRecord); memo.ifPresent(builder::setMemo); + batchKey.ifPresent(k -> builder.setBatchKey(k.apply(spec))); }; } diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/transactions/HapiTxnOp.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/transactions/HapiTxnOp.java index ed0a200d1fc0..2871e9a907f4 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/transactions/HapiTxnOp.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/transactions/HapiTxnOp.java @@ -67,6 +67,7 @@ import com.hederahashgraph.api.proto.java.TransactionBody; import com.hederahashgraph.api.proto.java.TransactionGetReceiptResponse; import com.hederahashgraph.api.proto.java.TransactionReceipt; +import com.hederahashgraph.api.proto.java.TransactionRecord; import com.hederahashgraph.api.proto.java.TransactionResponse; import edu.umd.cs.findbugs.annotations.NonNull; import edu.umd.cs.findbugs.annotations.Nullable; @@ -871,4 +872,19 @@ public boolean hasActualStatus() { public ResponseCodeEnum getActualStatus() { return lastReceipt.getStatus(); } + + public void updateStateFromRecord(TransactionRecord record, HapiSpec spec) throws Throwable { + this.actualStatus = record.getReceipt().getStatus(); + this.lastReceipt = record.getReceipt(); + updateStateOf(spec); + } + + public T batchKey(String key) { + batchKey = Optional.of(spec -> spec.registry().getKey(key)); + return self(); + } + + public Optional getNode() { + return node; + } } diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/transactions/TxnFactory.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/transactions/TxnFactory.java index a7900ead9a39..3a6f9e44f47a 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/transactions/TxnFactory.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/transactions/TxnFactory.java @@ -26,6 +26,7 @@ import com.hedera.services.bdd.spec.HapiSpec; import com.hedera.services.bdd.spec.HapiSpecSetup; import com.hedera.services.bdd.spec.utilops.mod.BodyMutation; +import com.hederahashgraph.api.proto.java.AtomicBatchTransactionBody; import com.hederahashgraph.api.proto.java.ConsensusCreateTopicTransactionBody; import com.hederahashgraph.api.proto.java.ConsensusDeleteTopicTransactionBody; import com.hederahashgraph.api.proto.java.ConsensusSubmitMessageTransactionBody; @@ -467,4 +468,8 @@ public Consumer defaultDefTokenClaimAi public Consumer defaultDefTokenAirdropTransactionBody() { return builder -> {}; } + + public Consumer defaultDefAtomicBatchTransactionBody() { + return builder -> {}; + } } diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/transactions/TxnVerbs.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/transactions/TxnVerbs.java index f7035f217e84..b287affaae6c 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/transactions/TxnVerbs.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/transactions/TxnVerbs.java @@ -99,6 +99,7 @@ import com.hedera.services.bdd.spec.transactions.token.HapiTokenUpdateNfts; import com.hedera.services.bdd.spec.transactions.token.HapiTokenWipe; import com.hedera.services.bdd.spec.transactions.token.TokenMovement; +import com.hedera.services.bdd.spec.transactions.util.HapiAtomicBatch; import com.hedera.services.bdd.spec.transactions.util.HapiUtilPrng; import com.hedera.services.bdd.spec.utilops.CustomSpecAssert; import com.hederahashgraph.api.proto.java.ContractCreateTransactionBody; @@ -770,4 +771,8 @@ public static HapiUtilPrng hapiPrng() { public static HapiUtilPrng hapiPrng(int range) { return new HapiUtilPrng(range); } + + public static HapiAtomicBatch atomicBatch(HapiTxnOp... ops) { + return new HapiAtomicBatch(ops); + } } diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/transactions/util/HapiAtomicBatch.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/transactions/util/HapiAtomicBatch.java new file mode 100644 index 000000000000..c020fe614796 --- /dev/null +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/transactions/util/HapiAtomicBatch.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2020-2025 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.services.bdd.spec.transactions.util; + +import static com.hedera.services.bdd.spec.queries.QueryVerbs.getTxnRecord; +import static com.hedera.services.bdd.spec.transactions.TxnUtils.extractTxnId; +import static com.hedera.services.bdd.spec.transactions.TxnUtils.suFrom; +import static com.hederahashgraph.api.proto.java.ResponseCodeEnum.SUCCESS; + +import com.google.common.base.MoreObjects; +import com.hedera.node.app.hapi.fees.usage.BaseTransactionMeta; +import com.hedera.node.app.hapi.fees.usage.crypto.CryptoCreateMeta; +import com.hedera.node.app.hapi.fees.usage.state.UsageAccumulator; +import com.hedera.node.app.hapi.utils.fee.SigValueObj; +import com.hedera.services.bdd.spec.HapiSpec; +import com.hedera.services.bdd.spec.fees.AdapterUtils; +import com.hedera.services.bdd.spec.queries.meta.HapiGetTxnRecord; +import com.hedera.services.bdd.spec.transactions.HapiTxnOp; +import com.hederahashgraph.api.proto.java.AtomicBatchTransactionBody; +import com.hederahashgraph.api.proto.java.FeeData; +import com.hederahashgraph.api.proto.java.HederaFunctionality; +import com.hederahashgraph.api.proto.java.Key; +import com.hederahashgraph.api.proto.java.Transaction; +import com.hederahashgraph.api.proto.java.TransactionBody; +import com.hederahashgraph.api.proto.java.TransactionID; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Function; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public class HapiAtomicBatch extends HapiTxnOp { + static final Logger log = LogManager.getLogger(HapiAtomicBatch.class); + + private static final String DEFAULT_NODE_ACCOUNT_ID = "0.0.0"; + private List> operationsToBatch; + private final Map> operationsMap = new HashMap<>(); + + public HapiAtomicBatch(HapiTxnOp... ops) { + this.operationsToBatch = Arrays.stream(ops).toList(); + } + + @Override + public HederaFunctionality type() { + return HederaFunctionality.AtomicBatch; + } + + @Override + protected HapiAtomicBatch self() { + return this; + } + + @Override + protected long feeFor(final HapiSpec spec, final Transaction txn, final int numPayerKeys) throws Throwable { + // TODO: Implement proper estimate for AtomicBatch + return 20_000_000L; // spec.fees().forActivityBasedOp(HederaFunctionality.AtomicBatch, this::usageEstimate, txn, + // numPayerKeys); + } + + private FeeData usageEstimate(final TransactionBody txn, final SigValueObj svo) { + // TODO: check for correct estimation of the batch + final var baseMeta = new BaseTransactionMeta(txn.getMemoBytes().size(), 0); + final var opMeta = new CryptoCreateMeta(txn.getCryptoCreateAccount()); + final var accumulator = new UsageAccumulator(); + cryptoOpsUsage.cryptoCreateUsage(suFrom(svo), baseMeta, opMeta, accumulator); + return AdapterUtils.feeDataFrom(accumulator); + } + + @Override + protected Consumer opBodyDef(final HapiSpec spec) throws Throwable { + final AtomicBatchTransactionBody opBody = spec.txns() + .body( + AtomicBatchTransactionBody.class, b -> { + for (HapiTxnOp op : operationsToBatch) { + try { + // set node account id to 0.0.0 if not set + if (op.getNode().isEmpty()) { + op.setNode(DEFAULT_NODE_ACCOUNT_ID); + } + // create a transaction for each operation + final var transaction = op.signedTxnFor(spec); + // save transaction id + final var txnId = extractTxnId(transaction); + operationsMap.put(txnId, op); + // add the transaction to the batch + b.addTransactions(transaction); + } catch (Throwable e) { + throw new RuntimeException(e); + } + } + }); + return b -> b.setAtomicBatch(opBody); + } + + @Override + public void updateStateOf(HapiSpec spec) throws Throwable { + if (actualStatus == SUCCESS) { + for (Map.Entry> entry : operationsMap.entrySet()) { + TransactionID txnId = entry.getKey(); + HapiTxnOp op = entry.getValue(); + + final HapiGetTxnRecord recordQuery = + getTxnRecord(txnId).noLogging().assertingNothing(); + final Optional error = recordQuery.execFor(spec); + if (error.isPresent()) { + throw error.get(); + } + op.updateStateFromRecord(recordQuery.getResponseRecord(), spec); + } + } + } + + @Override + protected List> defaultSigners() { + return Arrays.asList(spec -> spec.registry().getKey(effectivePayer(spec))); + } + + @Override + protected MoreObjects.ToStringHelper toStringHelper() { + return super.toStringHelper().add("range", operationsToBatch); + } +} diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/hip551/AtomicBatchTest.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/hip551/AtomicBatchTest.java new file mode 100644 index 000000000000..fdd43e2d7c12 --- /dev/null +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/suites/hip551/AtomicBatchTest.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2022-2025 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.services.bdd.suites.hip551; + +import static com.hedera.services.bdd.spec.HapiSpec.hapiTest; +import static com.hedera.services.bdd.spec.queries.QueryVerbs.getAccountBalance; +import static com.hedera.services.bdd.spec.queries.QueryVerbs.getTxnRecord; +import static com.hedera.services.bdd.spec.transactions.TxnVerbs.atomicBatch; +import static com.hedera.services.bdd.spec.transactions.TxnVerbs.cryptoCreate; +import static com.hedera.services.bdd.spec.utilops.UtilVerbs.usableTxnIdNamed; +import static com.hedera.services.bdd.suites.HapiSuite.ONE_HBAR; + +import com.hedera.services.bdd.junit.HapiTest; +import com.hedera.services.bdd.junit.HapiTestLifecycle; +import java.util.stream.Stream; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DynamicTest; + +@HapiTestLifecycle +public class AtomicBatchTest { + + @HapiTest + @Disabled + // just test that the batch is submitted + // disabled for now because there is no handler logic and streamValidation is failing in CI + public Stream simpleBatchTest() { + final var batchOperator = "batchOperator"; + final var innerTnxPayer = "innerPayer"; + final var innerTxnId = "innerId"; + + // create inner txn with: + // - custom txn id -> for getting the record + // - batch key -> for batch operator to sign + // - payer -> for paying the fee + final var innerTxn = cryptoCreate("foo") + .balance(ONE_HBAR) + .txnId(innerTxnId) + .batchKey(batchOperator) + .payingWith(innerTnxPayer); + + return hapiTest( + // create batch operator + cryptoCreate(batchOperator).balance(ONE_HBAR), + // create another payer for the inner txn + cryptoCreate(innerTnxPayer).balance(ONE_HBAR), + // use custom txn id so we can get the record + usableTxnIdNamed(innerTxnId).payerId(innerTnxPayer), + // create a batch txn + atomicBatch(innerTxn).payingWith(batchOperator), + // get and log inner txn record + getTxnRecord(innerTxnId).assertingNothingAboutHashes().logged(), + // validate the batch txn result + getAccountBalance("foo").hasTinyBars(ONE_HBAR)); + } +}