Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -1888,8 +1888,14 @@ enum ResponseCodeEnum {
* The HookCall set in the transaction is invalid
*/
INVALID_HOOK_CALL = 522;

/**
* Hooks are not supported to be used in TokenAirdrop transactions
*/
HOOKS_ARE_NOT_SUPPORTED_IN_AIRDROPS = 523;

/**
* The node account submitting the transaction has zero balance
*/
NODE_ACCOUNT_HAS_ZERO_BALANCE = 524;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -47,6 +48,7 @@
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;
Expand Down Expand Up @@ -229,6 +231,19 @@ public void verifyReadyForTransactions() throws PreCheckException {
}
}

public void verifyNodeAccountBalance(ReadableStoreFactory storeFactory, Account payerAccount)
throws PreCheckException {
final var accountStore = storeFactory.getStore(ReadableAccountStore.class);
final var nodeAccount =
accountStore.getAccountById(networkInfo.selfNodeInfo().accountId());
if (nodeAccount == null) {
throw new PreCheckException(INVALID_NODE_ACCOUNT);
}
if (nodeAccount.tinybarBalance() < 1 && !isSystemAccount(payerAccount)) {
throw new PreCheckException(NODE_ACCOUNT_HAS_ZERO_BALANCE);
}
}

/**
* Runs all the ingest checks on a {@link Transaction}
*
Expand Down Expand Up @@ -317,6 +332,7 @@ private void runAllChecks(
logger.warn("Payer account {} has no key, indicating a problem with state", txInfo.payerID());
throw new PreCheckException(UNAUTHORIZED);
}
verifyNodeAccountBalance(storeFactory, payer);

// 6. Verify payer's signatures
verifyPayerSignature(txInfo, payer, configuration);
Expand Down Expand Up @@ -554,4 +570,9 @@ private void verifyPayerSignature(
throw new PreCheckException(INVALID_SIGNATURE);
}
}

public static boolean isSystemAccount(@NonNull Account account) {
requireNonNull(account);
return account.accountIdOrThrow().accountNumOrThrow() <= 1000L;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like that we are hard coding the number of the system accounts like this. It should have its own dynamic property. I see that contract service is using it as 750 in ProcessorModule instead of 1000! Lets do the following:

  • Confirm what the right value is.
  • File and issue to make the range of system accounts a dynamic property and scrub the codebase to make that it is used everywhere. Making it a dynamic property will be useful in the case of spheres.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scratch that. There is a dynamic property already. LedgerConfig.numSystemAccounts we should use it instead. I will file a bug against contract service.

Copy link
Contributor Author

@ibankov ibankov Oct 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LedgerConfig.numSystemAccounts default value is 100, we should probably use numReservedSystemEntities in the same class which is 750

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a lot of accounts and other entities below numReservedSystemEntities that should not be able to bypass this check. Even in the System Accounts (at least on Hedera) there are 20-30 Node accounts that probably shouldn't have this privilege.

Checking below numSystemAccounts is OK, but we might be better off checking if the account is a privileged account specifically (I believe those are, for Hedera, 2 and 42-60, they'll vary for other Hiero networks, the exact value is, or perhaps should be, defined in api-permissions.properties).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I switched it to use AccountsConfig.isSuperUser() so only the treasury and system admin accounts are privileged for that. This should be fine as this is mostly for funding accounts after genesis which will be done by the treasury because its the only account with balance.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we already know which accounts are privileged for each transaction type in PrivilegesVerifier, we can re-use that and call PrivilegesVerifier.hasPrivileges here. If its privileged account, we can by pass that check?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to using Authorizer

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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, payer);

// 3.iv Calculate costs
final var queryFees = handler.computeFees(context).totalFee();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -57,17 +58,20 @@
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.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;
Expand Down Expand Up @@ -300,6 +304,76 @@ 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),
mock(Authorizer.class),
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, payerAccount))
.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(payerAccount.accountIdOrThrow())
.thenReturn(AccountID.newBuilder().accountNum(2000L).build());
assertThatThrownBy(() -> subject.verifyNodeAccountBalance(storeFactory, payerAccount))
.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, payerAccount))
.doesNotThrowAnyException();
}
}

@Nested
@DisplayName("1. Check the syntax")
class SyntaxCheckTests {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,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;
Expand All @@ -39,6 +42,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;
Expand Down Expand Up @@ -126,6 +130,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;
Expand Down Expand Up @@ -936,9 +941,31 @@ 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 String asAccountString(final com.hedera.hapi.node.base.AccountID accountID) {
return String.format("%d.%d.%d", accountID.shardNum(), accountID.realmNum(), accountID.accountNum());
}

private void buildRemoteNetwork() {
try {
targetNetwork = RemoteNetworkFactory.newWithTargetFrom(hapiSetup.remoteNodesYmlLoc());
Expand Down
Loading