diff --git a/hapi/hedera-protobuf-java-api/src/main/proto/services/response_code.proto b/hapi/hedera-protobuf-java-api/src/main/proto/services/response_code.proto index abae35b7af90..d7dac4c0cd55 100644 --- a/hapi/hedera-protobuf-java-api/src/main/proto/services/response_code.proto +++ b/hapi/hedera-protobuf-java-api/src/main/proto/services/response_code.proto @@ -1888,6 +1888,7 @@ enum ResponseCodeEnum { * The HookCall set in the transaction is invalid */ INVALID_HOOK_CALL = 522; + /** * Hooks are not supported to be used in TokenAirdrop transactions */ @@ -1909,4 +1910,9 @@ enum ResponseCodeEnum { * They are only supported in a top level CryptoTransfer transaction. */ HOOKS_EXECUTIONS_REQUIRE_TOP_LEVEL_CRYPTO_TRANSFER = 525; + + /** + * The node account submitting the transaction has zero balance + */ + NODE_ACCOUNT_HAS_ZERO_BALANCE = 526; } diff --git a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/ingest/IngestChecker.java b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/ingest/IngestChecker.java index 245753faba64..9a96d6b7df87 100644 --- a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/ingest/IngestChecker.java +++ b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/ingest/IngestChecker.java @@ -21,6 +21,7 @@ import static com.hedera.hapi.node.base.ResponseCodeEnum.HOOKS_NOT_ENABLED; import static com.hedera.hapi.node.base.ResponseCodeEnum.INVALID_NODE_ACCOUNT; import static com.hedera.hapi.node.base.ResponseCodeEnum.INVALID_SIGNATURE; +import static com.hedera.hapi.node.base.ResponseCodeEnum.NODE_ACCOUNT_HAS_ZERO_BALANCE; import static com.hedera.hapi.node.base.ResponseCodeEnum.NOT_SUPPORTED; import static com.hedera.hapi.node.base.ResponseCodeEnum.PLATFORM_NOT_ACTIVE; import static com.hedera.hapi.node.base.ResponseCodeEnum.UNAUTHORIZED; @@ -47,11 +48,13 @@ import com.hedera.node.app.fees.FeeManager; import com.hedera.node.app.hapi.utils.EthSigsUtils; import com.hedera.node.app.info.CurrentPlatformStatus; +import com.hedera.node.app.service.token.ReadableAccountStore; import com.hedera.node.app.signature.DefaultKeyVerifier; import com.hedera.node.app.signature.ExpandedSignaturePair; import com.hedera.node.app.signature.SignatureExpander; import com.hedera.node.app.signature.SignatureVerifier; import com.hedera.node.app.spi.authorization.Authorizer; +import com.hedera.node.app.spi.authorization.SystemPrivilege; import com.hedera.node.app.spi.fees.FeeContext; import com.hedera.node.app.spi.info.NetworkInfo; import com.hedera.node.app.spi.signatures.SignatureVerification; @@ -229,6 +232,31 @@ public void verifyReadyForTransactions() throws PreCheckException { } } + public void verifyNodeAccountBalance(final ReadableStoreFactory storeFactory, final TransactionInfo txInfo) + throws PreCheckException { + final var accountStore = storeFactory.getStore(ReadableAccountStore.class); + final var selfAccountId = networkInfo.selfNodeInfo().accountId(); + final var selfAccount = accountStore.getAccountById(selfAccountId); + + if (selfAccount == null) { + throw new PreCheckException(INVALID_NODE_ACCOUNT); + } + + final var nodeBalance = selfAccount.tinybarBalance(); + final var authorization = + authorizer.hasPrivilegedAuthorization(txInfo.payerID(), txInfo.functionality(), txInfo.txBody()); + + // if privileged authorization is UNNECESSARY check for superuser else check specific admin accounts + final var isPrivilegedAuthorized = authorization == SystemPrivilege.UNNECESSARY + ? authorizer.isSuperUser(txInfo.payerID()) + : authorization == SystemPrivilege.AUTHORIZED; + + // Check node account balance and authorization + if (nodeBalance < 1 && !isPrivilegedAuthorized) { + throw new PreCheckException(NODE_ACCOUNT_HAS_ZERO_BALANCE); + } + } + /** * Runs all the ingest checks on a {@link Transaction} * @@ -317,6 +345,7 @@ private void runAllChecks( logger.warn("Payer account {} has no key, indicating a problem with state", txInfo.payerID()); throw new PreCheckException(UNAUTHORIZED); } + verifyNodeAccountBalance(storeFactory, txInfo); // 6. Verify payer's signatures verifyPayerSignature(txInfo, payer, configuration); diff --git a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/prehandle/PreHandleContextImpl.java b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/prehandle/PreHandleContextImpl.java index 2a841f262dfb..87ddd7b6493a 100644 --- a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/prehandle/PreHandleContextImpl.java +++ b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/prehandle/PreHandleContextImpl.java @@ -520,6 +520,13 @@ public TransactionKeys allKeysForTransaction(@NonNull TransactionBody body, @Non return context; } + /** + * Gets the key for the given account. This doesn't work for aliases. + * + * @param accountID The ID of the account whose key is to be fetched + * @return The key for the account + * @throws PreCheckException if the account does not exist or is deleted + */ @Override public Key getAccountKey(@NonNull final AccountID accountID) throws PreCheckException { requireNonNull(accountID); diff --git a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/query/QueryWorkflowImpl.java b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/query/QueryWorkflowImpl.java index 5e57b41687f8..262a64bf1583 100644 --- a/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/query/QueryWorkflowImpl.java +++ b/hedera-node/hedera-app/src/main/java/com/hedera/node/app/workflows/query/QueryWorkflowImpl.java @@ -229,6 +229,7 @@ public void handleQuery(@NonNull final Bytes requestBuffer, @NonNull final Buffe // This should never happen, because the account is checked in the pure checks throw new PreCheckException(PAYER_ACCOUNT_NOT_FOUND); } + ingestChecker.verifyNodeAccountBalance(storeFactory, txInfo); // 3.iv Calculate costs final var queryFees = handler.computeFees(context).totalFee(); diff --git a/hedera-node/hedera-app/src/test/java/com/hedera/node/app/workflows/ingest/IngestCheckerTest.java b/hedera-node/hedera-app/src/test/java/com/hedera/node/app/workflows/ingest/IngestCheckerTest.java index 71870fc74aef..1334fe044e35 100644 --- a/hedera-node/hedera-app/src/test/java/com/hedera/node/app/workflows/ingest/IngestCheckerTest.java +++ b/hedera-node/hedera-app/src/test/java/com/hedera/node/app/workflows/ingest/IngestCheckerTest.java @@ -15,6 +15,7 @@ import static com.hedera.hapi.node.base.ResponseCodeEnum.INVALID_SIGNATURE; import static com.hedera.hapi.node.base.ResponseCodeEnum.INVALID_TRANSACTION; import static com.hedera.hapi.node.base.ResponseCodeEnum.INVALID_TRANSACTION_BODY; +import static com.hedera.hapi.node.base.ResponseCodeEnum.NODE_ACCOUNT_HAS_ZERO_BALANCE; import static com.hedera.hapi.node.base.ResponseCodeEnum.NOT_SUPPORTED; import static com.hedera.hapi.node.base.ResponseCodeEnum.PLATFORM_NOT_ACTIVE; import static com.hedera.hapi.node.base.ResponseCodeEnum.UNAUTHORIZED; @@ -57,17 +58,21 @@ import com.hedera.node.app.fixtures.AppTestBase; import com.hedera.node.app.info.CurrentPlatformStatus; import com.hedera.node.app.info.NodeInfoImpl; +import com.hedera.node.app.service.token.ReadableAccountStore; import com.hedera.node.app.signature.SignatureExpander; import com.hedera.node.app.signature.SignatureVerificationFuture; import com.hedera.node.app.signature.SignatureVerifier; import com.hedera.node.app.spi.authorization.Authorizer; +import com.hedera.node.app.spi.authorization.SystemPrivilege; import com.hedera.node.app.spi.fees.Fees; +import com.hedera.node.app.spi.info.NetworkInfo; import com.hedera.node.app.spi.info.NodeInfo; import com.hedera.node.app.spi.signatures.SignatureVerification; import com.hedera.node.app.spi.workflows.InsufficientBalanceException; import com.hedera.node.app.spi.workflows.PreCheckException; import com.hedera.node.app.state.DeduplicationCache; import com.hedera.node.app.state.recordcache.DeduplicationCacheImpl; +import com.hedera.node.app.store.ReadableStoreFactory; import com.hedera.node.app.throttle.SynchronizedThrottleAccumulator; import com.hedera.node.app.workflows.OpWorkflowMetrics; import com.hedera.node.app.workflows.SolvencyPreCheck; @@ -300,6 +305,90 @@ void testRunAllChecksSuccessfully() throws Exception { verify(opWorkflowMetrics, never()).incrementThrottled(any()); } + @Nested + class IngestCheckerNodeAccountBalanceTest { + private IngestChecker subject; + private ReadableStoreFactory storeFactory; + private ReadableAccountStore accountStore; + private Account nodeAccount; + private Account payerAccount; + + @BeforeEach + void setUp() { + storeFactory = mock(ReadableStoreFactory.class); + accountStore = mock(ReadableAccountStore.class); + nodeAccount = mock(Account.class); + payerAccount = mock(Account.class); + var networkInfo = mock(NetworkInfo.class); + + // Setup node account id + var nodeAccountId = AccountID.newBuilder().accountNum(1234L).build(); + var nodeInfo = mock(NodeInfo.class); + when(nodeInfo.accountId()).thenReturn(nodeAccountId); + when(networkInfo.selfNodeInfo()).thenReturn(nodeInfo); + + subject = new IngestChecker( + networkInfo, + mock(CurrentPlatformStatus.class), + mock(BlockStreamManager.class), + mock(TransactionChecker.class), + mock(SolvencyPreCheck.class), + mock(SignatureExpander.class), + mock(SignatureVerifier.class), + mock(DeduplicationCache.class), + mock(TransactionDispatcher.class), + mock(FeeManager.class), + authorizer, + mock(SynchronizedThrottleAccumulator.class), + mock(java.time.InstantSource.class), + mock(OpWorkflowMetrics.class), + null); + when(storeFactory.getStore(ReadableAccountStore.class)).thenReturn(accountStore); + } + + @Test + void throwsIfNodeAccountNotFound() { + when(accountStore.getAccountById(any())).thenReturn(null); + assertThatThrownBy(() -> subject.verifyNodeAccountBalance(storeFactory, transactionInfo)) + .isInstanceOf(PreCheckException.class) + .hasFieldOrPropertyWithValue("responseCode", INVALID_NODE_ACCOUNT); + } + + @Test + void throwsIfNodeAccountHasZeroBalanceAndPayerIsNotSystemAccount() { + when(accountStore.getAccountById(any())).thenReturn(nodeAccount); + when(nodeAccount.tinybarBalance()).thenReturn(0L); + // Simulate non-system account + when(authorizer.hasPrivilegedAuthorization(any(), any(), any())).thenReturn(SystemPrivilege.UNAUTHORIZED); + assertThatThrownBy(() -> subject.verifyNodeAccountBalance(storeFactory, transactionInfo)) + .isInstanceOf(PreCheckException.class) + .hasFieldOrPropertyWithValue("responseCode", NODE_ACCOUNT_HAS_ZERO_BALANCE); + } + + @Test + void validateBehaviorWhenAuthorizerReturnsUnnecessary() { + when(accountStore.getAccountById(any())).thenReturn(nodeAccount); + when(nodeAccount.tinybarBalance()).thenReturn(0L); + // Simulate non-system account + when(authorizer.hasPrivilegedAuthorization(any(), any(), any())).thenReturn(SystemPrivilege.UNNECESSARY); + when(authorizer.isSuperUser(any())).thenReturn(true); + assertThatCode(() -> subject.verifyNodeAccountBalance(storeFactory, transactionInfo)) + .doesNotThrowAnyException(); + when(authorizer.isSuperUser(any())).thenReturn(false); + assertThatThrownBy(() -> subject.verifyNodeAccountBalance(storeFactory, transactionInfo)) + .isInstanceOf(PreCheckException.class) + .hasFieldOrPropertyWithValue("responseCode", NODE_ACCOUNT_HAS_ZERO_BALANCE); + } + + @Test + void succeedsIfNodeAccountHasBalance() { + when(accountStore.getAccountById(any())).thenReturn(nodeAccount); + when(nodeAccount.tinybarBalance()).thenReturn(100L); + assertThatCode(() -> subject.verifyNodeAccountBalance(storeFactory, transactionInfo)) + .doesNotThrowAnyException(); + } + } + @Nested @DisplayName("1. Check the syntax") class SyntaxCheckTests { diff --git a/hedera-node/hedera-app/src/testFixtures/java/com/hedera/node/app/fixtures/AppTestBase.java b/hedera-node/hedera-app/src/testFixtures/java/com/hedera/node/app/fixtures/AppTestBase.java index 3f02b2c4aa40..2cd5d314a733 100644 --- a/hedera-node/hedera-app/src/testFixtures/java/com/hedera/node/app/fixtures/AppTestBase.java +++ b/hedera-node/hedera-app/src/testFixtures/java/com/hedera/node/app/fixtures/AppTestBase.java @@ -177,6 +177,7 @@ public WritableStates getWritableStates(@NonNull String serviceName) { protected Account nodeSelfAccount = Account.newBuilder() .accountId(nodeSelfAccountId) + .tinybarBalance(100_000_000) .key(FAKE_ED25519_KEY_INFOS[0].publicKey()) .declineReward(true) .build(); diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/HapiPropertySource.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/HapiPropertySource.java index f174f66a9c6a..068acb9fd98b 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/HapiPropertySource.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/HapiPropertySource.java @@ -372,6 +372,10 @@ static String asAccountString(AccountID account) { return asEntityString(account.getShardNum(), account.getRealmNum(), account.getAccountNum()); } + static String asAccountString(final com.hedera.hapi.node.base.AccountID accountID) { + return asEntityString(accountID.shardNum(), accountID.realmNum(), accountID.accountNumOrThrow()); + } + static String asAliasableAccountString(final AccountID account) { if (account.getAlias().isEmpty()) { return asAccountString(account); diff --git a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/HapiSpec.java b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/HapiSpec.java index aef54ab2fbac..504d3e25bc06 100644 --- a/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/HapiSpec.java +++ b/hedera-node/test-clients/src/main/java/com/hedera/services/bdd/spec/HapiSpec.java @@ -11,6 +11,7 @@ import static com.hedera.services.bdd.junit.hedera.BlockNodeNetwork.BLOCK_NODE_LOCAL_PORT; import static com.hedera.services.bdd.junit.hedera.ExternalPath.RECORD_STREAMS_DIR; import static com.hedera.services.bdd.junit.support.StreamFileAccess.STREAM_FILE_ACCESS; +import static com.hedera.services.bdd.spec.HapiPropertySource.asAccountString; import static com.hedera.services.bdd.spec.HapiSpec.SpecStatus.ERROR; import static com.hedera.services.bdd.spec.HapiSpec.SpecStatus.FAILED; import static com.hedera.services.bdd.spec.HapiSpec.SpecStatus.FAILED_AS_EXPECTED; @@ -22,11 +23,14 @@ import static com.hedera.services.bdd.spec.HapiSpecSetup.setupFrom; import static com.hedera.services.bdd.spec.infrastructure.HapiClients.clientsFor; import static com.hedera.services.bdd.spec.keys.DefaultKeyGen.DEFAULT_KEY_GEN; +import static com.hedera.services.bdd.spec.queries.QueryVerbs.getAccountBalance; import static com.hedera.services.bdd.spec.transactions.TxnUtils.doIfNotInterrupted; import static com.hedera.services.bdd.spec.transactions.TxnUtils.resourceAsString; import static com.hedera.services.bdd.spec.transactions.TxnUtils.turnLoggingOff; import static com.hedera.services.bdd.spec.transactions.TxnVerbs.cryptoTransfer; import static com.hedera.services.bdd.spec.transactions.crypto.HapiCryptoTransfer.tinyBarsFromTo; +import static com.hedera.services.bdd.spec.transactions.token.TokenMovement.movingHbar; +import static com.hedera.services.bdd.spec.utilops.CustomSpecAssert.allRunFor; import static com.hedera.services.bdd.spec.utilops.SysFileOverrideOp.Target.FEES; import static com.hedera.services.bdd.spec.utilops.SysFileOverrideOp.Target.THROTTLES; import static com.hedera.services.bdd.spec.utilops.UtilStateChange.createEthereumAccountForSpec; @@ -39,6 +43,7 @@ import static com.hedera.services.bdd.suites.HapiSuite.ETH_SUFFIX; import static com.hedera.services.bdd.suites.HapiSuite.GENESIS; import static com.hedera.services.bdd.suites.HapiSuite.ONE_HBAR; +import static com.hedera.services.bdd.suites.HapiSuite.ONE_HUNDRED_HBARS; import static com.hedera.services.bdd.suites.HapiSuite.SECP_256K1_SOURCE_KEY; import static java.util.Collections.emptyList; import static java.util.Objects.requireNonNull; @@ -126,6 +131,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import java.util.function.LongFunction; @@ -936,9 +942,27 @@ private boolean init() { status = ERROR; return false; } + fundNodeAccounts(); return true; } + private void fundNodeAccounts() { + targetNetwork.nodes().forEach(node -> { + final AtomicLong nodeBalance = new AtomicLong(0); + final var nodeAccount = node.getAccountId(); + if (nodeAccount != null) { + allRunFor(this, getAccountBalance(asAccountString(nodeAccount)).exposingBalanceTo(nodeBalance::set)); + if (nodeBalance.get() <= 0) { + allRunFor( + this, + cryptoTransfer(movingHbar(ONE_HUNDRED_HBARS) + .between(setup().defaultPayerName(), asAccountString(nodeAccount))) + .memo("Initial funding for node account " + nodeAccount.accountNum())); + } + } + }); + } + private void buildRemoteNetwork() { try { targetNetwork = RemoteNetworkFactory.newWithTargetFrom(hapiSetup.remoteNodesYmlLoc());