From fafbc1d38477a0d05b4a9f2904fcea90f3f0ba3f Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Sat, 9 May 2026 20:39:04 +0800 Subject: [PATCH 1/3] feat(framework): support solidity conditional shutdown (#6734) --- .../common/application/ApplicationImpl.java | 9 +- .../common/client/DatabaseGrpcClient.java | 2 +- .../main/java/org/tron/core/db/Manager.java | 17 - .../org/tron/core/net/TronNetDelegate.java | 13 + .../main/java/org/tron/program/FullNode.java | 25 +- .../java/org/tron/program/SolidityNode.java | 460 ++++++------- .../java/org/tron/core/db/ManagerTest.java | 6 - .../tron/core/net/TronNetDelegateTest.java | 82 +++ .../org/tron/program/SolidityNodeTest.java | 620 +++++++++++++++--- 9 files changed, 895 insertions(+), 339 deletions(-) diff --git a/framework/src/main/java/org/tron/common/application/ApplicationImpl.java b/framework/src/main/java/org/tron/common/application/ApplicationImpl.java index 4f31b2815ad..bab95d299ab 100644 --- a/framework/src/main/java/org/tron/common/application/ApplicationImpl.java +++ b/framework/src/main/java/org/tron/common/application/ApplicationImpl.java @@ -10,6 +10,7 @@ import org.tron.core.db.Manager; import org.tron.core.net.TronNetService; import org.tron.core.services.event.EventService; +import org.tron.program.SolidityNode; @Slf4j(topic = "app") @Component @@ -33,6 +34,9 @@ public class ApplicationImpl implements Application { @Autowired private ConsensusService consensusService; + @Autowired(required = false) + private SolidityNode solidityNode; + private final CountDownLatch shutdown = new CountDownLatch(1); /** @@ -50,11 +54,14 @@ public void startup() { @Override public void shutdown() { this.shutdownServices(); - if (!Args.getInstance().isSolidityNode() && (!Args.getInstance().p2pDisable)) { + if (!Args.getInstance().isSolidityNode() && !Args.getInstance().p2pDisable) { tronNetService.close(); } consensusService.stop(); eventService.close(); + if (solidityNode != null) { + solidityNode.close(); + } dbManager.close(); shutdown.countDown(); } diff --git a/framework/src/main/java/org/tron/common/client/DatabaseGrpcClient.java b/framework/src/main/java/org/tron/common/client/DatabaseGrpcClient.java index 0c22c264188..25f9fa60c4e 100644 --- a/framework/src/main/java/org/tron/common/client/DatabaseGrpcClient.java +++ b/framework/src/main/java/org/tron/common/client/DatabaseGrpcClient.java @@ -45,7 +45,7 @@ public Block getBlock(long blockNum) { } public void shutdown() { - channel.shutdown(); + channel.shutdownNow(); } public DynamicProperties getDynamicProperties() { diff --git a/framework/src/main/java/org/tron/core/db/Manager.java b/framework/src/main/java/org/tron/core/db/Manager.java index 1d3aec2554d..699c6181f17 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -1042,23 +1042,6 @@ public void eraseBlock() { } } - public void pushVerifiedBlock(BlockCapsule block) throws ContractValidateException, - ContractExeException, ValidateSignatureException, AccountResourceInsufficientException, - TransactionExpirationException, TooBigTransactionException, DupTransactionException, - TaposException, ValidateScheduleException, ReceiptCheckErrException, - VMIllegalException, TooBigTransactionResultException, UnLinkedBlockException, - NonCommonBlockException, BadNumberBlockException, BadBlockException, ZksnarkException, - EventBloomException { - block.generatedByMyself = true; - long start = System.currentTimeMillis(); - pushBlock(block); - logger.info("Push block cost: {} ms, blockNum: {}, blockHash: {}, trx count: {}.", - System.currentTimeMillis() - start, - block.getNum(), - block.getBlockId(), - block.getTransactions().size()); - } - private void applyBlock(BlockCapsule block) throws ContractValidateException, ContractExeException, ValidateSignatureException, AccountResourceInsufficientException, TransactionExpirationException, TooBigTransactionException, DupTransactionException, diff --git a/framework/src/main/java/org/tron/core/net/TronNetDelegate.java b/framework/src/main/java/org/tron/core/net/TronNetDelegate.java index f9214f99e04..804c3fffa39 100644 --- a/framework/src/main/java/org/tron/core/net/TronNetDelegate.java +++ b/framework/src/main/java/org/tron/core/net/TronNetDelegate.java @@ -233,6 +233,19 @@ public Message getData(Sha256Hash hash, InventoryType type) throws P2pException } } + public void pushVerifiedBlock(BlockCapsule block) throws P2pException { + block.generatedByMyself = true; + long start = System.currentTimeMillis(); + processBlock(block, true); + if (!hitDown) { + logger.info("Push block cost: {} ms, blockNum: {}, blockHash: {}, trx count: {}.", + System.currentTimeMillis() - start, + block.getNum(), + block.getBlockId(), + block.getTransactions().size()); + } + } + public void processBlock(BlockCapsule block, boolean isSync) throws P2pException { if (!hitDown && dbManager.getLatestSolidityNumShutDown() > 0 && dbManager.getLatestSolidityNumShutDown() == dbManager.getDynamicPropertiesStore() diff --git a/framework/src/main/java/org/tron/program/FullNode.java b/framework/src/main/java/org/tron/program/FullNode.java index 95257d77f8e..308cb9a1c69 100644 --- a/framework/src/main/java/org/tron/program/FullNode.java +++ b/framework/src/main/java/org/tron/program/FullNode.java @@ -2,6 +2,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.util.ObjectUtils; import org.tron.common.application.Application; import org.tron.common.application.ApplicationFactory; import org.tron.common.application.TronApplicationContext; @@ -28,19 +29,23 @@ public static void main(String[] args) { LogService.load(parameter.getLogbackPath()); - if (parameter.isSolidityNode()) { - SolidityNode.start(); - return; - } if (parameter.isKeystoreFactory()) { KeystoreFactory.start(); return; } - logger.info("Full node running."); - if (Args.getInstance().isDebug()) { - logger.info("in debug mode, it won't check energy time"); + if (parameter.isSolidityNode()) { + logger.info("Solidity node is running."); + if (ObjectUtils.isEmpty(parameter.getTrustNodeAddr())) { + throw new TronError(new IllegalArgumentException("Trust node is not set."), + TronError.ErrCode.SOLID_NODE_INIT); + } } else { - logger.info("not in debug mode, it will check energy time"); + logger.info("Full node running."); + if (Args.getInstance().isDebug()) { + logger.info("in debug mode, it won't check energy time"); + } else { + logger.info("not in debug mode, it will check energy time"); + } } // init metrics first @@ -55,6 +60,10 @@ public static void main(String[] args) { Application appT = ApplicationFactory.create(context); context.registerShutdownHook(); appT.startup(); + if (parameter.isSolidityNode()) { + SolidityNode node = context.getBean(SolidityNode.class); + node.run(); + } appT.blockUntilShutdown(); } diff --git a/framework/src/main/java/org/tron/program/SolidityNode.java b/framework/src/main/java/org/tron/program/SolidityNode.java index 6ffa3b3ce92..0998d8846c0 100644 --- a/framework/src/main/java/org/tron/program/SolidityNode.java +++ b/framework/src/main/java/org/tron/program/SolidityNode.java @@ -1,227 +1,233 @@ -package org.tron.program; - -import static org.tron.core.config.Parameter.ChainConstant.BLOCK_PRODUCED_INTERVAL; - -import com.google.common.annotations.VisibleForTesting; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.LinkedBlockingDeque; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.support.DefaultListableBeanFactory; -import org.springframework.util.ObjectUtils; -import org.tron.common.application.Application; -import org.tron.common.application.ApplicationFactory; -import org.tron.common.application.TronApplicationContext; -import org.tron.common.client.DatabaseGrpcClient; -import org.tron.common.es.ExecutorServiceManager; -import org.tron.common.parameter.CommonParameter; -import org.tron.common.prometheus.Metrics; -import org.tron.core.ChainBaseManager; -import org.tron.core.capsule.BlockCapsule; -import org.tron.core.config.DefaultConfig; -import org.tron.core.db.Manager; -import org.tron.core.exception.TronError; -import org.tron.protos.Protocol.Block; - -@Slf4j(topic = "app") -public class SolidityNode { - - private Manager dbManager; - - private ChainBaseManager chainBaseManager; - - private DatabaseGrpcClient databaseGrpcClient; - - private AtomicLong ID = new AtomicLong(); - - private AtomicLong remoteBlockNum = new AtomicLong(); - - private LinkedBlockingDeque blockQueue = new LinkedBlockingDeque<>(100); - - private int exceptionSleepTime = 1000; - - private volatile boolean flag = true; - - private ExecutorService getBlockEs; - private ExecutorService processBlockEs; - - public SolidityNode(Manager dbManager) { - this.dbManager = dbManager; - this.chainBaseManager = dbManager.getChainBaseManager(); - resolveCompatibilityIssueIfUsingFullNodeDatabase(); - ID.set(chainBaseManager.getDynamicPropertiesStore().getLatestSolidifiedBlockNum()); - databaseGrpcClient = new DatabaseGrpcClient(CommonParameter.getInstance().getTrustNodeAddr()); - remoteBlockNum.set(getLastSolidityBlockNum()); - } - - /** - * Start the SolidityNode. - */ - public static void start() { - logger.info("Solidity node is running."); - CommonParameter parameter = CommonParameter.getInstance(); - if (ObjectUtils.isEmpty(parameter.getTrustNodeAddr())) { - throw new TronError(new IllegalArgumentException("Trust node is not set."), - TronError.ErrCode.SOLID_NODE_INIT); - } - // init metrics first - Metrics.init(); - - DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); - beanFactory.setAllowCircularReferences(false); - TronApplicationContext context = - new TronApplicationContext(beanFactory); - context.register(DefaultConfig.class); - context.refresh(); - Application appT = ApplicationFactory.create(context); - context.registerShutdownHook(); - appT.startup(); - SolidityNode node = new SolidityNode(appT.getDbManager()); - node.run(); - awaitShutdown(appT, node); - } - - @VisibleForTesting - static void awaitShutdown(Application appT, SolidityNode node) { - try { - appT.blockUntilShutdown(); - } finally { - // SolidityNode is created manually rather than managed by Spring/Application, - // so its executors must be shut down explicitly on exit. - node.shutdown(); - } - } - - private void run() { - try { - getBlockEs = ExecutorServiceManager.newSingleThreadExecutor("solid-get-block"); - processBlockEs = ExecutorServiceManager.newSingleThreadExecutor("solid-process-block"); - getBlockEs.execute(this::getBlock); - processBlockEs.execute(this::processBlock); - logger.info("Success to start solid node, ID: {}, remoteBlockNum: {}.", ID.get(), - remoteBlockNum); - } catch (Exception e) { - logger.error("Failed to start solid node, address: {}.", - CommonParameter.getInstance().getTrustNodeAddr()); - throw new TronError(e, TronError.ErrCode.SOLID_NODE_INIT); - } - } - - public void shutdown() { - flag = false; - // Signal both pools before awaiting either so they drain concurrently - getBlockEs.shutdown(); - processBlockEs.shutdown(); - ExecutorServiceManager.shutdownAndAwaitTermination(getBlockEs, "solid-get-block"); - ExecutorServiceManager.shutdownAndAwaitTermination(processBlockEs, "solid-process-block"); - } - - private void getBlock() { - long blockNum = ID.incrementAndGet(); - while (flag) { - try { - if (blockNum > remoteBlockNum.get()) { - sleep(BLOCK_PRODUCED_INTERVAL); - remoteBlockNum.set(getLastSolidityBlockNum()); - continue; - } - Block block = getBlockByNum(blockNum); - blockQueue.put(block); - blockNum = ID.incrementAndGet(); - } catch (Exception e) { - logger.error("Failed to get block {}, reason: {}.", blockNum, e.getMessage()); - sleep(exceptionSleepTime); - } - } - } - - private void processBlock() { - while (flag) { - try { - Block block = blockQueue.take(); - loopProcessBlock(block); - } catch (Exception e) { - logger.error(e.getMessage()); - sleep(exceptionSleepTime); - } - } - } - - private void loopProcessBlock(Block block) { - while (flag) { - long blockNum = block.getBlockHeader().getRawData().getNumber(); - try { - dbManager.pushVerifiedBlock(new BlockCapsule(block)); - chainBaseManager.getDynamicPropertiesStore().saveLatestSolidifiedBlockNum(blockNum); - logger - .info("Success to process block: {}, blockQueueSize: {}.", blockNum, blockQueue.size()); - return; - } catch (Exception e) { - logger.error("Failed to process block {}.", new BlockCapsule(block), e); - sleep(exceptionSleepTime); - block = getBlockByNum(blockNum); - } - } - } - - private Block getBlockByNum(long blockNum) { - while (flag) { - try { - long time = System.currentTimeMillis(); - Block block = databaseGrpcClient.getBlock(blockNum); - long num = block.getBlockHeader().getRawData().getNumber(); - if (num == blockNum) { - logger.info("Success to get block: {}, cost: {}ms.", - blockNum, System.currentTimeMillis() - time); - return block; - } else { - logger.warn("Get block id not the same , {}, {}.", num, blockNum); - sleep(exceptionSleepTime); - } - } catch (Exception e) { - logger.error("Failed to get block: {}, reason: {}.", blockNum, e.getMessage()); - sleep(exceptionSleepTime); - } - } - return null; - } - - private long getLastSolidityBlockNum() { - while (flag) { - try { - long time = System.currentTimeMillis(); - long blockNum = databaseGrpcClient.getDynamicProperties().getLastSolidityBlockNum(); - logger.info("Get last remote solid blockNum: {}, remoteBlockNum: {}, cost: {}.", - blockNum, remoteBlockNum, System.currentTimeMillis() - time); - return blockNum; - } catch (Exception e) { - logger.error("Failed to get last solid blockNum: {}, reason: {}.", remoteBlockNum.get(), - e.getMessage()); - sleep(exceptionSleepTime); - } - } - return 0; - } - - public void sleep(long time) { - try { - Thread.sleep(time); - } catch (Exception e1) { - logger.error(e1.getMessage()); - } - } - - private void resolveCompatibilityIssueIfUsingFullNodeDatabase() { - long lastSolidityBlockNum = - chainBaseManager.getDynamicPropertiesStore().getLatestSolidifiedBlockNum(); - long headBlockNum = chainBaseManager.getHeadBlockNum(); - logger.info("headBlockNum:{}, solidityBlockNum:{}, diff:{}", - headBlockNum, lastSolidityBlockNum, headBlockNum - lastSolidityBlockNum); - if (lastSolidityBlockNum < headBlockNum) { - logger.info("use fullNode database, headBlockNum:{}, solidityBlockNum:{}, diff:{}", - headBlockNum, lastSolidityBlockNum, headBlockNum - lastSolidityBlockNum); - chainBaseManager.getDynamicPropertiesStore().saveLatestSolidifiedBlockNum(headBlockNum); - } - } -} +package org.tron.program; + +import static org.tron.core.config.Parameter.ChainConstant.BLOCK_PRODUCED_INTERVAL; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.event.ContextClosedEvent; +import org.springframework.core.type.AnnotatedTypeMetadata; +import org.springframework.stereotype.Component; +import org.tron.common.client.DatabaseGrpcClient; +import org.tron.common.es.ExecutorServiceManager; +import org.tron.common.parameter.CommonParameter; +import org.tron.core.ChainBaseManager; +import org.tron.core.capsule.BlockCapsule; +import org.tron.core.config.args.Args; +import org.tron.core.exception.TronError; +import org.tron.core.net.TronNetDelegate; +import org.tron.protos.Protocol.Block; + +@Slf4j(topic = "app") +@Conditional(SolidityNode.SolidityCondition.class) +@Component +public class SolidityNode implements ApplicationListener { + + @Autowired + private ChainBaseManager chainBaseManager; + + @Autowired + private TronNetDelegate tronNetDelegate; + + private DatabaseGrpcClient databaseGrpcClient; + + private final AtomicLong ID = new AtomicLong(); + + private final AtomicLong remoteBlockNum = new AtomicLong(); + + private final LinkedBlockingDeque blockQueue = new LinkedBlockingDeque<>(100); + + private final int exceptionSleepTime = 1000; + + private volatile boolean flag = true; + + private final String getBlockName = "get-block"; + private final String processBlockName = "process-block"; + + private ExecutorService getBlockExecutor; + private ExecutorService processBlockExecutor; + + @PostConstruct + private void init() { + resolveCompatibilityIssueIfUsingFullNodeDatabase(); + ID.set(chainBaseManager.getDynamicPropertiesStore().getLatestSolidifiedBlockNum()); + getBlockExecutor = ExecutorServiceManager.newSingleThreadExecutor(getBlockName); + processBlockExecutor = ExecutorServiceManager.newSingleThreadExecutor(processBlockName); + } + + @Override + public void onApplicationEvent(ContextClosedEvent event) { + flag = false; // invoke earlier than @PreDestroy + } + + public void close() { + flag = false; + if (databaseGrpcClient != null) { + databaseGrpcClient.shutdown(); + } + // Interrupt get-block immediately: it may be stuck in blockQueue.put() (full queue, + // process-block stopped) or in a gRPC blocking stub call. + // Do NOT interrupt process-block: let it finish its current pushVerifiedBlock naturally + // (flag=false causes the while-loop to exit within 1-2 s) so the DB flush can complete + // cleanly before ApplicationImpl.shutdown() tears down the underlying executor. + getBlockExecutor.shutdownNow(); + ExecutorServiceManager.shutdownAndAwaitTermination(getBlockExecutor, getBlockName); + ExecutorServiceManager.shutdownAndAwaitTermination(processBlockExecutor, processBlockName); + } + + @PreDestroy + private void shutdown() { + close(); + } + + public void run() { + try { + databaseGrpcClient = new DatabaseGrpcClient(CommonParameter.getInstance().getTrustNodeAddr()); + remoteBlockNum.set(getLastSolidityBlockNum()); + + getBlockExecutor.submit(this::getBlock); + processBlockExecutor.submit(this::processSolidityBlock); + logger.info("Success to start solid node, ID: {}, remoteBlockNum: {}.", ID.get(), + remoteBlockNum); + } catch (Exception e) { + logger.error("Failed to start solid node, address: {}.", + CommonParameter.getInstance().getTrustNodeAddr()); + throw new TronError(e, TronError.ErrCode.SOLID_NODE_INIT); + } + } + + private void getBlock() { + long blockNum = ID.incrementAndGet(); + while (flag && !tronNetDelegate.isHitDown()) { + try { + if (blockNum > remoteBlockNum.get()) { + sleep(BLOCK_PRODUCED_INTERVAL); + remoteBlockNum.set(getLastSolidityBlockNum()); + continue; + } + Block block = getBlockByNum(blockNum); + blockQueue.put(block); + blockNum = ID.incrementAndGet(); + } catch (Exception e) { + logger.error("Failed to get block {}, reason: {}.", blockNum, e.getMessage()); + sleep(exceptionSleepTime); + } + } + } + + private void processSolidityBlock() { + while (flag && !tronNetDelegate.isHitDown()) { + try { + Block block = blockQueue.poll(exceptionSleepTime, TimeUnit.MILLISECONDS); + if (block == null) { + continue; + } + loopProcessBlock(block); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.info("processSolidityBlock interrupted."); + return; + } catch (Exception e) { + logger.error(e.getMessage()); + sleep(exceptionSleepTime); + } + } + } + + private void loopProcessBlock(Block block) { + while (flag) { + long blockNum = block.getBlockHeader().getRawData().getNumber(); + try { + tronNetDelegate.pushVerifiedBlock(new BlockCapsule(block)); + if (!tronNetDelegate.isHitDown()) { + chainBaseManager.getDynamicPropertiesStore().saveLatestSolidifiedBlockNum(blockNum); + logger.info("Success to process block: {}, blockQueueSize: {}.", + blockNum, blockQueue.size()); + } + return; + } catch (Exception e) { + logger.error("Failed to process block {}.", new BlockCapsule(block), e); + sleep(exceptionSleepTime); + block = getBlockByNum(blockNum); + } + } + } + + private Block getBlockByNum(long blockNum) { + while (flag && !tronNetDelegate.isHitDown()) { + try { + long time = System.currentTimeMillis(); + Block block = databaseGrpcClient.getBlock(blockNum); + long num = block.getBlockHeader().getRawData().getNumber(); + if (num == blockNum) { + logger.info("Success to get block: {}, cost: {}ms.", + blockNum, System.currentTimeMillis() - time); + return block; + } else { + logger.warn("Get block id not the same , {}, {}.", num, blockNum); + sleep(exceptionSleepTime); + } + } catch (Exception e) { + logger.error("Failed to get block: {}, reason: {}.", blockNum, e.getMessage()); + sleep(exceptionSleepTime); + } + } + //throw RuntimeException instead of return null to avoid NullPointException + throw new RuntimeException("SolidityNode is closing."); + } + + private long getLastSolidityBlockNum() { + while (flag && !tronNetDelegate.isHitDown()) { + try { + long time = System.currentTimeMillis(); + long blockNum = databaseGrpcClient.getDynamicProperties().getLastSolidityBlockNum(); + logger.info("Get last remote solid blockNum: {}, remoteBlockNum: {}, cost: {}.", + blockNum, remoteBlockNum, System.currentTimeMillis() - time); + return blockNum; + } catch (Exception e) { + logger.error("Failed to get last solid blockNum: {}, reason: {}.", remoteBlockNum.get(), + e.getMessage()); + sleep(exceptionSleepTime); + } + } + return 0; + } + + public void sleep(long time) { + try { + Thread.sleep(time); + } catch (Exception e1) { + logger.error(e1.getMessage()); + } + } + + private void resolveCompatibilityIssueIfUsingFullNodeDatabase() { + long lastSolidityBlockNum = + chainBaseManager.getDynamicPropertiesStore().getLatestSolidifiedBlockNum(); + long headBlockNum = chainBaseManager.getHeadBlockNum(); + logger.info("headBlockNum:{}, solidityBlockNum:{}, diff:{}", + headBlockNum, lastSolidityBlockNum, headBlockNum - lastSolidityBlockNum); + if (lastSolidityBlockNum < headBlockNum) { + logger.info("use fullNode database, headBlockNum:{}, solidityBlockNum:{}, diff:{}", + headBlockNum, lastSolidityBlockNum, headBlockNum - lastSolidityBlockNum); + chainBaseManager.getDynamicPropertiesStore().saveLatestSolidifiedBlockNum(headBlockNum); + } + } + + static class SolidityCondition implements Condition { + + @Override + public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { + return Args.getInstance().isSolidityNode(); + } + } +} diff --git a/framework/src/test/java/org/tron/core/db/ManagerTest.java b/framework/src/test/java/org/tron/core/db/ManagerTest.java index eb0f8a7d5bf..87b4fcfdc77 100755 --- a/framework/src/test/java/org/tron/core/db/ManagerTest.java +++ b/framework/src/test/java/org/tron/core/db/ManagerTest.java @@ -319,12 +319,6 @@ public void transactionTest() { } catch (Exception e) { Assert.assertTrue(e instanceof TaposException); } - try { - dbManager.pushVerifiedBlock(chainManager.getHead()); - dbManager.getBlockChainHashesOnFork(chainManager.getHeadBlockId()); - } catch (Exception e) { - Assert.assertTrue(e instanceof TaposException); - } } @Test diff --git a/framework/src/test/java/org/tron/core/net/TronNetDelegateTest.java b/framework/src/test/java/org/tron/core/net/TronNetDelegateTest.java index 3083d36425a..7e584581d2b 100644 --- a/framework/src/test/java/org/tron/core/net/TronNetDelegateTest.java +++ b/framework/src/test/java/org/tron/core/net/TronNetDelegateTest.java @@ -16,8 +16,10 @@ import org.tron.core.capsule.BlockCapsule; import org.tron.core.capsule.TransactionCapsule; import org.tron.core.config.args.Args; +import org.tron.core.db.Manager; import org.tron.core.exception.P2pException; import org.tron.core.exception.P2pException.TypeEnum; +import org.tron.core.store.DynamicPropertiesStore; import org.tron.protos.Protocol.Transaction.Contract.ContractType; import org.tron.protos.contract.BalanceContract.TransferContract; @@ -58,6 +60,86 @@ public void test() throws Exception { Assert.assertTrue(!tronNetDelegate.isBlockUnsolidified()); } + // ── pushVerifiedBlock tests ─────────────────────────────────────────────────── + + /** + * When hitDown is already true, processBlock returns immediately without + * calling pushBlock and pushVerifiedBlock must not throw. + */ + @Test + public void testPushVerifiedBlockSkipsWhenHitDown() throws Exception { + Args.setParam(new String[] {}, TestConstants.TEST_CONF); + TronNetDelegate tronNetDelegate = new TronNetDelegate(); + setField(tronNetDelegate, "hitDown", true); + + BlockCapsule block = new BlockCapsule(1, Sha256Hash.ZERO_HASH, 0L, ByteString.EMPTY); + tronNetDelegate.pushVerifiedBlock(block); + + Assert.assertTrue(block.generatedByMyself); + Assert.assertTrue(tronNetDelegate.isHitDown()); + } + + /** + * When the conditional-shutdown threshold is reached, processBlock must set + * hitDown=true and return without calling pushBlock. + */ + @Test + public void testPushVerifiedBlockTriggersShutdown() throws Exception { + Args.setParam(new String[] {}, TestConstants.TEST_CONF); + TronNetDelegate tronNetDelegate = new TronNetDelegate(); + tronNetDelegate.init(); + tronNetDelegate.setExit(false); // prevent System.exit(0) in hit-thread + + Manager dbManager = Mockito.mock(Manager.class); + Mockito.when(dbManager.getLatestSolidityNumShutDown()).thenReturn(50L); + DynamicPropertiesStore store = Mockito.mock(DynamicPropertiesStore.class); + Mockito.when(store.getLatestBlockHeaderNumberFromDB()).thenReturn(50L); + Mockito.when(dbManager.getDynamicPropertiesStore()).thenReturn(store); + setField(tronNetDelegate, "dbManager", dbManager); + + BlockCapsule block = new BlockCapsule(1, Sha256Hash.ZERO_HASH, 0L, ByteString.EMPTY); + try { + tronNetDelegate.pushVerifiedBlock(block); + } finally { + tronNetDelegate.close(); + } + + Assert.assertTrue(tronNetDelegate.isHitDown()); + Mockito.verify(dbManager, Mockito.never()).pushBlock(Mockito.any()); + } + + /** + * On the normal (non-shutdown) path pushBlock must be called exactly once. + */ + @Test + public void testPushVerifiedBlockPushesBlock() throws Exception { + Args.setParam(new String[] {}, TestConstants.TEST_CONF); + TronNetDelegate tronNetDelegate = new TronNetDelegate(); + + Manager dbManager = Mockito.mock(Manager.class); + Mockito.when(dbManager.getLatestSolidityNumShutDown()).thenReturn(0L); + Mockito.when(dbManager.getBlockedTimer()).thenReturn(new ThreadLocal<>()); + + ChainBaseManager chainBaseManager = Mockito.mock(ChainBaseManager.class); + Mockito.when(chainBaseManager.getHeadBlockId()) + .thenReturn(new BlockCapsule.BlockId(Sha256Hash.ZERO_HASH, 0L)); + + setField(tronNetDelegate, "dbManager", dbManager); + setField(tronNetDelegate, "chainBaseManager", chainBaseManager); + + BlockCapsule block = new BlockCapsule(1, Sha256Hash.ZERO_HASH, 0L, ByteString.EMPTY); + tronNetDelegate.pushVerifiedBlock(block); + + Assert.assertTrue(block.generatedByMyself); + Mockito.verify(dbManager, Mockito.times(1)).pushBlock(Mockito.any()); + } + + private static void setField(Object obj, String name, Object value) throws Exception { + Field f = obj.getClass().getDeclaredField(name); + f.setAccessible(true); + f.set(obj, value); + } + @Test public void testValidBlockMerkleRoot() throws Exception { Args.setParam(new String[] {}, TestConstants.TEST_CONF); diff --git a/framework/src/test/java/org/tron/program/SolidityNodeTest.java b/framework/src/test/java/org/tron/program/SolidityNodeTest.java index f5b525cd445..a02eb22364e 100755 --- a/framework/src/test/java/org/tron/program/SolidityNodeTest.java +++ b/framework/src/test/java/org/tron/program/SolidityNodeTest.java @@ -1,34 +1,41 @@ package org.tron.program; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; -import static org.mockito.Mockito.doCallRealMethod; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.inOrder; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; +import com.google.protobuf.ByteString; import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.concurrent.ExecutorService; +import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; import javax.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.junit.Assert; import org.junit.Rule; import org.junit.Test; import org.junit.rules.Timeout; -import org.mockito.InOrder; +import org.mockito.Mockito; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.context.event.ContextClosedEvent; +import org.springframework.core.type.AnnotatedTypeMetadata; import org.tron.common.BaseTest; import org.tron.common.TestConstants; -import org.tron.common.application.Application; import org.tron.common.client.DatabaseGrpcClient; -import org.tron.common.es.ExecutorServiceManager; +import org.tron.common.utils.ByteArray; import org.tron.common.utils.PublicMethod; +import org.tron.core.ChainBaseManager; import org.tron.core.config.args.Args; -import org.tron.core.exception.TronError; +import org.tron.core.net.TronNetDelegate; import org.tron.core.services.RpcApiService; import org.tron.core.services.http.solidity.SolidityNodeHttpApiService; +import org.tron.core.store.DynamicPropertiesStore; +import org.tron.protos.Protocol; import org.tron.protos.Protocol.Block; import org.tron.protos.Protocol.DynamicProperties; @@ -39,6 +46,9 @@ public class SolidityNodeTest extends BaseTest { RpcApiService rpcApiService; @Resource SolidityNodeHttpApiService solidityNodeHttpApiService; + @Resource + SolidityNode solidityNode; + static int rpcPort = PublicMethod.chooseRandomPort(); static int solidityHttpPort = PublicMethod.chooseRandomPort(); @@ -51,18 +61,22 @@ public class SolidityNodeTest extends BaseTest { Args.getInstance().setSolidityHttpPort(solidityHttpPort); } - @Test - public void testSolidityArgs() { - Assert.assertNotNull(Args.getInstance().getTrustNodeAddr()); - Assert.assertTrue(Args.getInstance().isSolidityNode()); - String trustNodeAddr = Args.getInstance().getTrustNodeAddr(); - Args.getInstance().setTrustNodeAddr(null); - TronError thrown = assertThrows(TronError.class, - SolidityNode::start); - assertEquals(TronError.ErrCode.SOLID_NODE_INIT, thrown.getErrCode()); - Args.getInstance().setTrustNodeAddr(trustNodeAddr); + // ── helpers ────────────────────────────────────────────────────────────────── + + private boolean getFlag() throws Exception { + Field f = SolidityNode.class.getDeclaredField("flag"); + f.setAccessible(true); + return (boolean) f.get(solidityNode); } + private void setFlag(boolean value) throws Exception { + Field f = SolidityNode.class.getDeclaredField("flag"); + f.setAccessible(true); + f.set(solidityNode, value); + } + + // ── existing tests ──────────────────────────────────────────────────────────── + @Test public void testSolidityGrpcCall() { rpcApiService.start(); @@ -101,93 +115,541 @@ public void testSolidityNodeHttpApiService() { Assert.assertTrue(true); } - @Test - public void testAwaitShutdownAlwaysStopsNode() { - Application app = mock(Application.class); - SolidityNode node = mock(SolidityNode.class); + // ── new tests ───────────────────────────────────────────────────────────────── - SolidityNode.awaitShutdown(app, node); + /** + * @PostConstruct init() must create both executor services before run() is called. + */ + @Test + public void testExecutorsInitializedOnStartup() throws Exception { + Field getBlockF = SolidityNode.class.getDeclaredField("getBlockExecutor"); + getBlockF.setAccessible(true); + Field processBlockF = SolidityNode.class.getDeclaredField("processBlockExecutor"); + processBlockF.setAccessible(true); - InOrder inOrder = inOrder(app, node); - inOrder.verify(app).blockUntilShutdown(); - inOrder.verify(node).shutdown(); + assertNotNull(getBlockF.get(solidityNode)); + assertNotNull(processBlockF.get(solidityNode)); + assertFalse(((ExecutorService) getBlockF.get(solidityNode)).isShutdown()); + assertFalse(((ExecutorService) processBlockF.get(solidityNode)).isShutdown()); } + /** + * onApplicationEvent() must set flag=false so threads stop before + * other beans' @PreDestroy methods are called. + */ @Test - public void testAwaitShutdownStopsNodeWhenBlockedCallFails() { - Application app = mock(Application.class); - SolidityNode node = mock(SolidityNode.class); - RuntimeException expected = new RuntimeException("boom"); - doThrow(expected).when(app).blockUntilShutdown(); + public void testOnApplicationEventSetsFlagFalse() throws Exception { + assertTrue(getFlag()); + solidityNode.onApplicationEvent(mock(ContextClosedEvent.class)); + assertFalse(getFlag()); + setFlag(true); // restore shared bean + } - RuntimeException thrown = assertThrows(RuntimeException.class, - () -> SolidityNode.awaitShutdown(app, node)); - assertSame(expected, thrown); + /** + * getBlockByNum() must throw RuntimeException (not return null) when + * flag=false, to prevent NullPointerException in blockQueue.put(). + */ + @Test(timeout = 1000) + public void testGetBlockByNumThrowsWhenClosed() throws Exception { + setFlag(false); + try { + Method m = SolidityNode.class.getDeclaredMethod("getBlockByNum", long.class); + m.setAccessible(true); + try { + m.invoke(solidityNode, 1L); + Assert.fail("Expected RuntimeException"); + } catch (InvocationTargetException e) { + assertTrue(e.getCause() instanceof RuntimeException); + assertEquals("SolidityNode is closing.", e.getCause().getMessage()); + } + } finally { + setFlag(true); + } + } - InOrder inOrder = inOrder(app, node); - inOrder.verify(app).blockUntilShutdown(); - inOrder.verify(node).shutdown(); + /** + * getLastSolidityBlockNum() must return 0 (not throw) when flag=false so + * getBlock()'s while(flag) loop exits quietly without a misleading error log. + */ + @Test(timeout = 1000) + public void testGetLastSolidityBlockNumReturnsZeroWhenClosed() throws Exception { + setFlag(false); + try { + Method m = SolidityNode.class.getDeclaredMethod("getLastSolidityBlockNum"); + m.setAccessible(true); + long result = (long) m.invoke(solidityNode); + assertEquals(0L, result); + } finally { + setFlag(true); + } } + /** + * SolidityCondition must match when --solidity is passed so the bean is + * registered in the Spring context. + */ @Test - public void testShutdownSetsFlagAndShutsDownExecutors() throws Exception { - SolidityNode node = mock(SolidityNode.class); - doCallRealMethod().when(node).shutdown(); + public void testSolidityConditionMatchesWhenSolidityFlagSet() { + assertTrue(Args.getInstance().isSolidityNode()); + SolidityNode.SolidityCondition condition = new SolidityNode.SolidityCondition(); + assertTrue(condition.matches( + mock(ConditionContext.class), + mock(AnnotatedTypeMetadata.class))); + } - ExecutorService es1 = ExecutorServiceManager.newSingleThreadExecutor("test-solid-get"); - ExecutorService es2 = ExecutorServiceManager.newSingleThreadExecutor("test-solid-process"); + // ── additional coverage tests ───────────────────────────────────────────────── - Field flagField = SolidityNode.class.getDeclaredField("flag"); - flagField.setAccessible(true); - flagField.set(node, true); + /** + * sleep() must return normally without throwing. + */ + @Test(timeout = 1000) + public void testSleepReturnsNormally() { + solidityNode.sleep(1); + } + + /** + * sleep() must swallow InterruptedException so callers are not surprised; + * the thread continues after waking. + */ + @Test(timeout = 5000) + public void testSleepHandlesInterrupt() throws InterruptedException { + Thread t = new Thread(() -> solidityNode.sleep(10_000)); + t.start(); + Thread.sleep(50); + t.interrupt(); + t.join(2000); + assertFalse("sleep() should have returned after interrupt", t.isAlive()); + } - Field getBlockEsField = SolidityNode.class.getDeclaredField("getBlockEs"); - getBlockEsField.setAccessible(true); - getBlockEsField.set(node, es1); + /** + * getBlockByNum() must return the block when the gRPC client returns a block + * whose number matches the requested number. + */ + @Test(timeout = 2000) + public void testGetBlockByNumReturnsMatchingBlock() throws Exception { + Block expected = blockWithNum(7L); + DatabaseGrpcClient mockClient = mock(DatabaseGrpcClient.class); + Mockito.when(mockClient.getBlock(7L)).thenReturn(expected); - Field processBlockEsField = SolidityNode.class.getDeclaredField("processBlockEs"); - processBlockEsField.setAccessible(true); - processBlockEsField.set(node, es2); + Field clientField = getField("databaseGrpcClient"); + Object orig = clientField.get(solidityNode); + clientField.set(solidityNode, mockClient); + try { + Method m = SolidityNode.class.getDeclaredMethod("getBlockByNum", long.class); + m.setAccessible(true); + Block result = (Block) m.invoke(solidityNode, 7L); + assertEquals(7L, result.getBlockHeader().getRawData().getNumber()); + } finally { + clientField.set(solidityNode, orig); + } + } - node.shutdown(); + /** + * getLastSolidityBlockNum() must return the value obtained from the gRPC + * client when the call succeeds. + */ + @Test(timeout = 2000) + public void testGetLastSolidityBlockNumReturnsFetchedValue() throws Exception { + DynamicProperties props = DynamicProperties.newBuilder() + .setLastSolidityBlockNum(99L).build(); + DatabaseGrpcClient mockClient = mock(DatabaseGrpcClient.class); + Mockito.when(mockClient.getDynamicProperties()).thenReturn(props); - Assert.assertFalse((boolean) flagField.get(node)); - Assert.assertTrue(es1.isShutdown()); - Assert.assertTrue(es2.isShutdown()); + Field clientField = getField("databaseGrpcClient"); + Object orig = clientField.get(solidityNode); + clientField.set(solidityNode, mockClient); + try { + Method m = SolidityNode.class.getDeclaredMethod("getLastSolidityBlockNum"); + m.setAccessible(true); + long result = (long) m.invoke(solidityNode); + assertEquals(99L, result); + } finally { + clientField.set(solidityNode, orig); + } } + /** + * loopProcessBlock() must persist the solidified block num when pushVerifiedBlock + * succeeds and hitDown is false. + */ + @Test(timeout = 5000) + public void testLoopProcessBlockSavesBlockNumWhenNotHitDown() throws Exception { + TronNetDelegate mockDelegate = mock(TronNetDelegate.class); + Mockito.when(mockDelegate.isHitDown()).thenReturn(false); + + long origSolidified = chainBaseManager.getDynamicPropertiesStore() + .getLatestSolidifiedBlockNum(); + Field delegateField = getField("tronNetDelegate"); + Object origDelegate = delegateField.get(solidityNode); + delegateField.set(solidityNode, mockDelegate); + try { + invokeLoopProcessBlock(blockWithNum(55L)); + assertEquals(55L, chainBaseManager.getDynamicPropertiesStore() + .getLatestSolidifiedBlockNum()); + } finally { + chainBaseManager.getDynamicPropertiesStore() + .saveLatestSolidifiedBlockNum(origSolidified); + delegateField.set(solidityNode, origDelegate); + } + } + + /** + * loopProcessBlock() must NOT persist the solidified block num when hitDown + * is true, because the block was never pushed to BlockStore. + */ + @Test(timeout = 2000) + public void testLoopProcessBlockSkipsSaveWhenHitDown() throws Exception { + TronNetDelegate mockDelegate = mock(TronNetDelegate.class); + Mockito.when(mockDelegate.isHitDown()).thenReturn(true); + + long origSolidified = chainBaseManager.getDynamicPropertiesStore() + .getLatestSolidifiedBlockNum(); + Field delegateField = getField("tronNetDelegate"); + Object origDelegate = delegateField.get(solidityNode); + delegateField.set(solidityNode, mockDelegate); + try { + invokeLoopProcessBlock(blockWithNum(56L)); + assertEquals(origSolidified, chainBaseManager.getDynamicPropertiesStore() + .getLatestSolidifiedBlockNum()); + } finally { + delegateField.set(solidityNode, origDelegate); + } + } + + /** + * resolveCompatibilityIssueIfUsingFullNodeDatabase() must update the solidified + * block num to match headBlockNum when solidity lags behind. + */ + @Test(timeout = 2000) + public void testResolveCompatibilityIssueWhenSolidityLagsHead() throws Exception { + DynamicPropertiesStore mockStore = mock(DynamicPropertiesStore.class); + Mockito.when(mockStore.getLatestSolidifiedBlockNum()).thenReturn(3L); + ChainBaseManager mockCbm = mock(ChainBaseManager.class); + Mockito.when(mockCbm.getDynamicPropertiesStore()).thenReturn(mockStore); + Mockito.when(mockCbm.getHeadBlockNum()).thenReturn(10L); + + Field cbmField = getField("chainBaseManager"); + Object orig = cbmField.get(solidityNode); + cbmField.set(solidityNode, mockCbm); + try { + Method m = SolidityNode.class.getDeclaredMethod( + "resolveCompatibilityIssueIfUsingFullNodeDatabase"); + m.setAccessible(true); + m.invoke(solidityNode); + } finally { + cbmField.set(solidityNode, orig); + } + Mockito.verify(mockStore).saveLatestSolidifiedBlockNum(10L); + } + + // ── shutdown / databaseGrpcClient lifecycle ────────────────────────────────── + + /** + * When databaseGrpcClient is non-null at shutdown time, its shutdown() must + * be called to close the gRPC channel. + */ @Test - public void testRunInitializesNamedExecutors() throws Exception { - rpcApiService.start(); - String originalAddr = Args.getInstance().getTrustNodeAddr(); - Args.getInstance().setTrustNodeAddr("127.0.0.1:" + rpcPort); + public void testShutdownCallsDatabaseClientShutdown() throws Exception { + // Use a standalone instance so we don't destroy the shared Spring executor services. + SolidityNode node = new SolidityNode(); + + DynamicPropertiesStore mockStore = mock(DynamicPropertiesStore.class); + ChainBaseManager mockCbm = mock(ChainBaseManager.class); + Mockito.when(mockCbm.getDynamicPropertiesStore()).thenReturn(mockStore); + Mockito.when(mockCbm.getHeadBlockNum()).thenReturn(0L); + getField("chainBaseManager").set(node, mockCbm); + + Method initM = SolidityNode.class.getDeclaredMethod("init"); + initM.setAccessible(true); + initM.invoke(node); + + DatabaseGrpcClient mockClient = mock(DatabaseGrpcClient.class); + getField("databaseGrpcClient").set(node, mockClient); + + Method shutdownM = SolidityNode.class.getDeclaredMethod("shutdown"); + shutdownM.setAccessible(true); + shutdownM.invoke(node); + + Mockito.verify(mockClient).shutdown(); + } + + // ── getBlock() ─────────────────────────────────────────────────────────────── + + /** + * getBlock() must fetch a block via gRPC, place it in blockQueue, then exit + * when flag becomes false after the first successful fetch. + */ + @Test(timeout = 5000) + @SuppressWarnings("unchecked") + public void testGetBlockProcessesOneBlock() throws Exception { + long origID = atomicLong("ID").get(); + long origRemote = atomicLong("remoteBlockNum").get(); + + atomicLong("ID").set(0L); + atomicLong("remoteBlockNum").set(2L); // blockNum=1 <= 2, no sleep needed + + DatabaseGrpcClient mockClient = mock(DatabaseGrpcClient.class); + Mockito.when(mockClient.getBlock(1L)).thenAnswer(inv -> { + setFlag(false); // stop the loop after this iteration + return blockWithNum(1L); + }); + + TronNetDelegate mockDelegate = mock(TronNetDelegate.class); + Mockito.when(mockDelegate.isHitDown()).thenReturn(false); + + Field clientField = getField("databaseGrpcClient"); + Field delegateField = getField("tronNetDelegate"); + Object origClient = clientField.get(solidityNode); + Object origDelegate = delegateField.get(solidityNode); + clientField.set(solidityNode, mockClient); + delegateField.set(solidityNode, mockDelegate); + + LinkedBlockingDeque queue = + (LinkedBlockingDeque) getField("blockQueue").get(solidityNode); + try { + Method m = SolidityNode.class.getDeclaredMethod("getBlock"); + m.setAccessible(true); + m.invoke(solidityNode); + + assertEquals(1, queue.size()); + assertEquals(1L, queue.peek().getBlockHeader().getRawData().getNumber()); + } finally { + setFlag(true); + queue.clear(); + atomicLong("ID").set(origID); + atomicLong("remoteBlockNum").set(origRemote); + clientField.set(solidityNode, origClient); + delegateField.set(solidityNode, origDelegate); + } + } + + // ── processSolidityBlock() ─────────────────────────────────────────────────── + + /** + * processSolidityBlock() must drain a block from the queue, process it, and + * exit when flag becomes false inside pushVerifiedBlock. + */ + @Test(timeout = 5000) + @SuppressWarnings("unchecked") + public void testProcessSolidityBlockProcessesQueuedBlock() throws Exception { + TronNetDelegate mockDelegate = mock(TronNetDelegate.class); + Mockito.when(mockDelegate.isHitDown()).thenReturn(false); + Mockito.doAnswer(inv -> { + setFlag(false); + return null; + }).when(mockDelegate).pushVerifiedBlock(Mockito.any()); + + long origSolidified = chainBaseManager.getDynamicPropertiesStore() + .getLatestSolidifiedBlockNum(); + Field delegateField = getField("tronNetDelegate"); + Object origDelegate = delegateField.get(solidityNode); + delegateField.set(solidityNode, mockDelegate); + + LinkedBlockingDeque queue = + (LinkedBlockingDeque) getField("blockQueue").get(solidityNode); + queue.put(blockWithNum(88L)); + try { + Method m = SolidityNode.class.getDeclaredMethod("processSolidityBlock"); + m.setAccessible(true); + m.invoke(solidityNode); + + assertEquals(88L, chainBaseManager.getDynamicPropertiesStore() + .getLatestSolidifiedBlockNum()); + } finally { + setFlag(true); + queue.clear(); + chainBaseManager.getDynamicPropertiesStore() + .saveLatestSolidifiedBlockNum(origSolidified); + delegateField.set(solidityNode, origDelegate); + } + } + + /** + * processSolidityBlock() must return cleanly when the thread is interrupted + * while waiting on blockQueue.poll(). + */ + @Test(timeout = 8000) + public void testProcessSolidityBlockHandlesInterrupt() throws Exception { + TronNetDelegate mockDelegate = mock(TronNetDelegate.class); + Mockito.when(mockDelegate.isHitDown()).thenReturn(false); + + Field delegateField = getField("tronNetDelegate"); + Object origDelegate = delegateField.get(solidityNode); + delegateField.set(solidityNode, mockDelegate); + + Method m = SolidityNode.class.getDeclaredMethod("processSolidityBlock"); + m.setAccessible(true); + Thread t = new Thread(() -> { + try { + m.invoke(solidityNode); + } catch (Exception ignored) { + // InvocationTargetException should not happen; the method handles interrupt internally + } + }); + try { + t.start(); + Thread.sleep(150); // let the thread enter blockQueue.poll(1000 ms) + t.interrupt(); + t.join(5000); + assertFalse("processSolidityBlock must exit after interrupt", t.isAlive()); + } finally { + setFlag(true); + delegateField.set(solidityNode, origDelegate); + } + } + + // ── loopProcessBlock() retry path ──────────────────────────────────────────── + + /** + * When pushVerifiedBlock throws, loopProcessBlock() must retry after sleeping, + * re-fetching the block via getBlockByNum, and ultimately succeed. + */ + @Test(timeout = 5000) + public void testLoopProcessBlockRetriesOnException() throws Exception { + TronNetDelegate mockDelegate = mock(TronNetDelegate.class); + Mockito.when(mockDelegate.isHitDown()).thenReturn(false); + Mockito.doThrow(new RuntimeException("push failed")) + .doNothing() + .when(mockDelegate).pushVerifiedBlock(Mockito.any()); + + DatabaseGrpcClient mockClient = mock(DatabaseGrpcClient.class); + Mockito.when(mockClient.getBlock(33L)).thenReturn(blockWithNum(33L)); + + long origSolidified = chainBaseManager.getDynamicPropertiesStore() + .getLatestSolidifiedBlockNum(); + Field delegateField = getField("tronNetDelegate"); + Field clientField = getField("databaseGrpcClient"); + Object origDelegate = delegateField.get(solidityNode); + Object origClient = clientField.get(solidityNode); + delegateField.set(solidityNode, mockDelegate); + clientField.set(solidityNode, mockClient); try { - SolidityNode node = new SolidityNode(dbManager); + invokeLoopProcessBlock(blockWithNum(33L)); + assertEquals(33L, chainBaseManager.getDynamicPropertiesStore() + .getLatestSolidifiedBlockNum()); + } catch (RuntimeException e) { + Assert.assertTrue(e.getMessage().contains("push failed")); + } finally { + chainBaseManager.getDynamicPropertiesStore() + .saveLatestSolidifiedBlockNum(origSolidified); + delegateField.set(solidityNode, origDelegate); + clientField.set(solidityNode, origClient); + } + } - Field flagField = SolidityNode.class.getDeclaredField("flag"); - flagField.setAccessible(true); - flagField.set(node, false); + // ── getBlockByNum() retry paths ────────────────────────────────────────────── - Method runMethod = SolidityNode.class.getDeclaredMethod("run"); - runMethod.setAccessible(true); - runMethod.invoke(node); + /** + * When the returned block number does not match, getBlockByNum() must warn + * and retry; it must throw RuntimeException when flag becomes false. + */ + @Test(timeout = 5000) + public void testGetBlockByNumWarnOnWrongNum() throws Exception { + DatabaseGrpcClient mockClient = mock(DatabaseGrpcClient.class); + Mockito.when(mockClient.getBlock(9L)).thenAnswer(inv -> { + setFlag(false); // cause the retry loop to exit + return blockWithNum(999L); // deliberately wrong number + }); - Field getBlockEsField = SolidityNode.class.getDeclaredField("getBlockEs"); - getBlockEsField.setAccessible(true); - Field processBlockEsField = SolidityNode.class.getDeclaredField("processBlockEs"); - processBlockEsField.setAccessible(true); + Field clientField = getField("databaseGrpcClient"); + Object orig = clientField.get(solidityNode); + clientField.set(solidityNode, mockClient); + try { + Method m = SolidityNode.class.getDeclaredMethod("getBlockByNum", long.class); + m.setAccessible(true); + try { + m.invoke(solidityNode, 9L); + Assert.fail("Expected RuntimeException"); + } catch (InvocationTargetException e) { + assertTrue(e.getCause() instanceof RuntimeException); + } + } finally { + setFlag(true); + clientField.set(solidityNode, orig); + } + } - ExecutorService getBlockEs = (ExecutorService) getBlockEsField.get(node); - ExecutorService processBlockEs = (ExecutorService) processBlockEsField.get(node); + /** + * When the gRPC call throws, getBlockByNum() must log, sleep, and retry; + * on the second attempt it must return the correct block. + */ + @Test(timeout = 5000) + public void testGetBlockByNumRetriesOnException() throws Exception { + DatabaseGrpcClient mockClient = mock(DatabaseGrpcClient.class); + Mockito.when(mockClient.getBlock(8L)) + .thenThrow(new RuntimeException("rpc error")) + .thenReturn(blockWithNum(8L)); - Assert.assertNotNull(getBlockEs); - Assert.assertNotNull(processBlockEs); + Field clientField = getField("databaseGrpcClient"); + Object orig = clientField.get(solidityNode); + clientField.set(solidityNode, mockClient); + try { + Method m = SolidityNode.class.getDeclaredMethod("getBlockByNum", long.class); + m.setAccessible(true); + Block result = (Block) m.invoke(solidityNode, 8L); + assertEquals(8L, result.getBlockHeader().getRawData().getNumber()); + } finally { + clientField.set(solidityNode, orig); + } + } + + // ── getLastSolidityBlockNum() retry path ───────────────────────────────────── - ExecutorServiceManager.shutdownAndAwaitTermination(getBlockEs, "test-solid-get"); - ExecutorServiceManager.shutdownAndAwaitTermination(processBlockEs, "test-solid-process"); + /** + * When getDynamicProperties() throws, getLastSolidityBlockNum() must log, + * sleep, and retry; on the second attempt it must return the fetched value. + */ + @Test(timeout = 5000) + public void testGetLastSolidityBlockNumRetriesOnException() throws Exception { + DynamicProperties props = DynamicProperties.newBuilder() + .setLastSolidityBlockNum(50L).build(); + DatabaseGrpcClient mockClient = mock(DatabaseGrpcClient.class); + Mockito.when(mockClient.getDynamicProperties()) + .thenThrow(new RuntimeException("rpc error")) + .thenReturn(props); + + Field clientField = getField("databaseGrpcClient"); + Object orig = clientField.get(solidityNode); + clientField.set(solidityNode, mockClient); + try { + Method m = SolidityNode.class.getDeclaredMethod("getLastSolidityBlockNum"); + m.setAccessible(true); + long result = (long) m.invoke(solidityNode); + assertEquals(50L, result); } finally { - Args.getInstance().setTrustNodeAddr(originalAddr); - rpcApiService.stop(); + clientField.set(solidityNode, orig); } } + + // ── private helpers ────────────────────────────────────────────────────────── + + private static Field getField(String name) throws Exception { + Field f = SolidityNode.class.getDeclaredField(name); + f.setAccessible(true); + return f; + } + + private AtomicLong atomicLong(String name) throws Exception { + return (AtomicLong) getField(name).get(solidityNode); + } + + private static Block blockWithNum(long num) { + return Block.newBuilder() + .setBlockHeader( + Protocol.BlockHeader.newBuilder() + .setRawData( + Protocol.BlockHeader.raw.newBuilder() + .setNumber(num) + .setParentHash(ByteString.copyFrom(ByteArray.fromHexString( + "0x0000000000000000000000000000000000000000000000000000000000000000"))) + .build()) + .build()) + .build(); + } + + private void invokeLoopProcessBlock(Block block) throws Exception { + Method m = SolidityNode.class.getDeclaredMethod("loopProcessBlock", Block.class); + m.setAccessible(true); + m.invoke(solidityNode, block); + } } From 03eca2977727551c50adecf717fbc2d30b502488 Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Sat, 9 May 2026 20:41:25 +0800 Subject: [PATCH 2/3] fix(jsonrpc): enforce log filter cap and improve match efficiency (#6732) --- .../common/es/ExecutorServiceManager.java | 19 ++ .../common/parameter/CommonParameter.java | 3 + .../org/tron/core/config/args/NodeConfig.java | 1 + common/src/main/resources/reference.conf | 5 +- .../capsule/BlockFilterCapsule.java | 11 +- .../capsule/FilterTriggerCapsule.java | 5 +- .../logsfilter/capsule/LogsFilterCapsule.java | 8 +- .../java/org/tron/core/config/args/Args.java | 1 + .../main/java/org/tron/core/db/Manager.java | 15 +- .../core/services/jsonrpc/JsonRpcApiUtil.java | 3 +- .../core/services/jsonrpc/TronJsonRpc.java | 3 +- .../services/jsonrpc/TronJsonRpcImpl.java | 144 +++++---- .../services/jsonrpc/filters/LogMatch.java | 10 +- framework/src/main/resources/config.conf | 4 +- .../common/logsfilter/FilterQueryTest.java | 8 - .../capsule/BlockFilterCapsuleTest.java | 1 - .../capsule/LogsFilterCapsuleTest.java | 1 - .../tron/common/runtime/vm/Create2Test.java | 3 +- .../core/jsonrpc/ConcurrentHashMapTest.java | 17 +- .../core/jsonrpc/HandleLogsFilterTest.java | 293 ++++++++++++++++++ .../JsonRpcCallAndEstimateGasTest.java | 12 +- .../tron/core/jsonrpc/JsonrpcServiceTest.java | 3 +- .../core/jsonrpc/LogMatchOverLimitTest.java | 151 +++++++++ .../tron/core/jsonrpc/WalletCursorTest.java | 58 +++- .../src/test/resources/config-localtest.conf | 1 + .../test/resources/config-test-mainnet.conf | 1 + framework/src/test/resources/config-test.conf | 1 + 27 files changed, 669 insertions(+), 113 deletions(-) create mode 100644 framework/src/test/java/org/tron/core/jsonrpc/HandleLogsFilterTest.java create mode 100644 framework/src/test/java/org/tron/core/jsonrpc/LogMatchOverLimitTest.java diff --git a/common/src/main/java/org/tron/common/es/ExecutorServiceManager.java b/common/src/main/java/org/tron/common/es/ExecutorServiceManager.java index 779a8edf75d..fb845ec211c 100644 --- a/common/src/main/java/org/tron/common/es/ExecutorServiceManager.java +++ b/common/src/main/java/org/tron/common/es/ExecutorServiceManager.java @@ -4,11 +4,14 @@ import java.util.concurrent.BlockingQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.ForkJoinWorkerThread; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import lombok.extern.slf4j.Slf4j; import org.tron.common.exit.ExitManager; @@ -35,6 +38,22 @@ public static ScheduledExecutorService newSingleThreadScheduledExecutor(String n new ThreadFactoryBuilder().setNameFormat(name).setDaemon(isDaemon).build()); } + public static ForkJoinPool newForkJoinPool(String name, int parallelism) { + return newForkJoinPool(name, parallelism, false); + } + + public static ForkJoinPool newForkJoinPool(String name, int parallelism, boolean isDaemon) { + AtomicInteger counter = new AtomicInteger(0); + ForkJoinPool.ForkJoinWorkerThreadFactory factory = pool -> { + ForkJoinWorkerThread thread = + ForkJoinPool.defaultForkJoinWorkerThreadFactory.newThread(pool); + thread.setName(name + "-" + counter.getAndIncrement()); + thread.setDaemon(isDaemon); + return thread; + }; + return new ForkJoinPool(parallelism, factory, null, false); + } + public static ExecutorService newFixedThreadPool(String name, int fixThreads) { return newFixedThreadPool(name, fixThreads, false); } diff --git a/common/src/main/java/org/tron/common/parameter/CommonParameter.java b/common/src/main/java/org/tron/common/parameter/CommonParameter.java index eaf56bf48e8..8f19e607497 100644 --- a/common/src/main/java/org/tron/common/parameter/CommonParameter.java +++ b/common/src/main/java/org/tron/common/parameter/CommonParameter.java @@ -492,6 +492,9 @@ public class CommonParameter { @Getter @Setter public int jsonRpcMaxBlockFilterNum = 50000; + @Getter + @Setter + public int jsonRpcMaxLogFilterNum = 20000; @Getter @Setter diff --git a/common/src/main/java/org/tron/core/config/args/NodeConfig.java b/common/src/main/java/org/tron/core/config/args/NodeConfig.java index 653fac2eb2c..d280336182d 100644 --- a/common/src/main/java/org/tron/core/config/args/NodeConfig.java +++ b/common/src/main/java/org/tron/core/config/args/NodeConfig.java @@ -311,6 +311,7 @@ public void setHttpPBFTPort(int v) { private int maxBlockRange = 5000; private int maxSubTopics = 1000; private int maxBlockFilterNum = 50000; + private int maxLogFilterNum = 20000; private long maxMessageSize = 4194304; } diff --git a/common/src/main/resources/reference.conf b/common/src/main/resources/reference.conf index 7f88719d7f8..b2e9898f27b 100644 --- a/common/src/main/resources/reference.conf +++ b/common/src/main/resources/reference.conf @@ -386,9 +386,12 @@ node { # Maximum topics within a topic criteria, >0 otherwise no limit maxSubTopics = 1000 - # Maximum number for blockFilter + # Maximum number for blockFilter. >0 otherwise no limit maxBlockFilterNum = 50000 + # Maximum number of concurrent eth_newFilter registrations, >0 otherwise no limit + maxLogFilterNum = 20000 + # Maximum JSON-RPC request body size, default 4MB. Independent from rpc.maxMessageSize. maxMessageSize = 4M } diff --git a/framework/src/main/java/org/tron/common/logsfilter/capsule/BlockFilterCapsule.java b/framework/src/main/java/org/tron/common/logsfilter/capsule/BlockFilterCapsule.java index 9cf3c0c690e..e0cfb6d4433 100644 --- a/framework/src/main/java/org/tron/common/logsfilter/capsule/BlockFilterCapsule.java +++ b/framework/src/main/java/org/tron/common/logsfilter/capsule/BlockFilterCapsule.java @@ -1,7 +1,5 @@ package org.tron.common.logsfilter.capsule; -import static org.tron.core.services.jsonrpc.TronJsonRpcImpl.handleBLockFilter; - import lombok.Getter; import lombok.Setter; import lombok.ToString; @@ -20,8 +18,7 @@ public class BlockFilterCapsule extends FilterTriggerCapsule { private boolean solidified; public BlockFilterCapsule(BlockCapsule block, boolean solidified) { - blockHash = block.getBlockId().toString(); - this.solidified = solidified; + this(block.getBlockId().toString(), solidified); } public BlockFilterCapsule(String blockHash, boolean solidified) { @@ -29,10 +26,4 @@ public BlockFilterCapsule(String blockHash, boolean solidified) { this.solidified = solidified; } - @Override - public void processFilterTrigger() { - handleBLockFilter(this); - } - } - diff --git a/framework/src/main/java/org/tron/common/logsfilter/capsule/FilterTriggerCapsule.java b/framework/src/main/java/org/tron/common/logsfilter/capsule/FilterTriggerCapsule.java index 0280f0c96a7..5d495a5c98c 100644 --- a/framework/src/main/java/org/tron/common/logsfilter/capsule/FilterTriggerCapsule.java +++ b/framework/src/main/java/org/tron/common/logsfilter/capsule/FilterTriggerCapsule.java @@ -1,8 +1,5 @@ package org.tron.common.logsfilter.capsule; -public class FilterTriggerCapsule extends TriggerCapsule { +public class FilterTriggerCapsule { - public void processFilterTrigger() { - throw new UnsupportedOperationException(); - } } \ No newline at end of file diff --git a/framework/src/main/java/org/tron/common/logsfilter/capsule/LogsFilterCapsule.java b/framework/src/main/java/org/tron/common/logsfilter/capsule/LogsFilterCapsule.java index 8a8e122d9a0..c6f35e736a3 100644 --- a/framework/src/main/java/org/tron/common/logsfilter/capsule/LogsFilterCapsule.java +++ b/framework/src/main/java/org/tron/common/logsfilter/capsule/LogsFilterCapsule.java @@ -1,7 +1,5 @@ package org.tron.common.logsfilter.capsule; -import static org.tron.core.services.jsonrpc.TronJsonRpcImpl.handleLogsFilter; - import java.util.List; import lombok.Getter; import lombok.Setter; @@ -43,8 +41,4 @@ public LogsFilterCapsule(long blockNumber, String blockHash, Bloom bloom, this.removed = removed; } - @Override - public void processFilterTrigger() { - handleLogsFilter(this); - } -} \ No newline at end of file +} diff --git a/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index abd625a462a..a97792f1a19 100644 --- a/framework/src/main/java/org/tron/core/config/args/Args.java +++ b/framework/src/main/java/org/tron/core/config/args/Args.java @@ -559,6 +559,7 @@ private static void applyNodeConfig(NodeConfig nc) { PARAMETER.jsonRpcMaxBlockRange = jsonrpc.getMaxBlockRange(); PARAMETER.jsonRpcMaxSubTopics = jsonrpc.getMaxSubTopics(); PARAMETER.jsonRpcMaxBlockFilterNum = jsonrpc.getMaxBlockFilterNum(); + PARAMETER.jsonRpcMaxLogFilterNum = jsonrpc.getMaxLogFilterNum(); PARAMETER.jsonRpcMaxMessageSize = jsonrpc.getMaxMessageSize(); // ---- P2P sub-bean ---- diff --git a/framework/src/main/java/org/tron/core/db/Manager.java b/framework/src/main/java/org/tron/core/db/Manager.java index 699c6181f17..a534b9d1c5d 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -48,6 +48,7 @@ import org.apache.commons.collections4.CollectionUtils; import org.bouncycastle.util.encoders.Hex; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; import org.tron.api.GrpcAPI; import org.tron.api.GrpcAPI.TransactionInfoList; @@ -142,6 +143,7 @@ import org.tron.core.service.MortgageService; import org.tron.core.service.RewardViCalService; import org.tron.core.services.event.exception.EventException; +import org.tron.core.services.jsonrpc.TronJsonRpcImpl; import org.tron.core.store.AccountAssetStore; import org.tron.core.store.AccountIdIndexStore; import org.tron.core.store.AccountIndexStore; @@ -277,6 +279,10 @@ public class Manager { @Autowired private RewardViCalService rewardViCalService; + @Lazy + @Autowired + private TronJsonRpcImpl tronJsonRpcImpl; + /** * Cycle thread to rePush Transactions */ @@ -333,8 +339,10 @@ public class Manager { while (isRunFilterProcessThread) { try { FilterTriggerCapsule filterCapsule = filterCapsuleQueue.poll(1, TimeUnit.SECONDS); - if (filterCapsule != null) { - filterCapsule.processFilterTrigger(); + if (filterCapsule instanceof LogsFilterCapsule) { + tronJsonRpcImpl.handleLogsFilter((LogsFilterCapsule) filterCapsule); + } else if (filterCapsule instanceof BlockFilterCapsule) { + tronJsonRpcImpl.handleBLockFilter((BlockFilterCapsule) filterCapsule); } } catch (InterruptedException e) { logger.error("FilterProcessLoop get InterruptedException, error is {}.", @@ -2268,7 +2276,8 @@ private void reOrgLogsFilter() { } private void postBlockFilter(final BlockCapsule blockCapsule, boolean solidified) { - BlockFilterCapsule blockFilterCapsule = new BlockFilterCapsule(blockCapsule, solidified); + BlockFilterCapsule blockFilterCapsule = + new BlockFilterCapsule(blockCapsule, solidified); if (!filterCapsuleQueue.offer(blockFilterCapsule)) { logger.info("Too many filters, block filter lost: {}.", blockCapsule.getBlockId()); } diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcApiUtil.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcApiUtil.java index 104b72a66e8..6a0957d62d2 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcApiUtil.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcApiUtil.java @@ -61,6 +61,8 @@ public class JsonRpcApiUtil { public static final String TAG_SAFE_SUPPORT_ERROR = "TAG safe not supported"; public static final String BLOCK_NUM_ERROR = "invalid block number"; + private static final SecureRandom random = new SecureRandom(); + public static byte[] convertToTronAddress(byte[] address) { byte[] newAddress = new byte[21]; byte[] temp = new byte[] {Wallet.getAddressPreFixByte()}; @@ -647,7 +649,6 @@ public static long parseBlockNumber(String blockNumOrTag, Wallet wallet) } public static String generateFilterId() { - SecureRandom random = new SecureRandom(); byte[] uid = new byte[16]; // 128 bits are converted to 16 bytes random.nextBytes(uid); return ByteArray.toHexString(uid); diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpc.java b/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpc.java index f5707148724..50da763b8b9 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpc.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpc.java @@ -291,9 +291,10 @@ CompilationResult ethSubmitHashrate(String hashrate, String id) @JsonRpcErrors({ @JsonRpcError(exception = JsonRpcMethodNotFoundException.class, code = -32601, data = "{}"), @JsonRpcError(exception = JsonRpcInvalidParamsException.class, code = -32602, data = "{}"), + @JsonRpcError(exception = JsonRpcExceedLimitException.class, code = -32005, data = "{}"), }) String newFilter(FilterRequest fr) throws JsonRpcInvalidParamsException, - JsonRpcMethodNotFoundException; + JsonRpcMethodNotFoundException, JsonRpcExceedLimitException; @JsonRpcMethod("eth_newBlockFilter") @JsonRpcErrors({ diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpcImpl.java b/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpcImpl.java index 40fafac535b..4d919b81ece 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpcImpl.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpcImpl.java @@ -14,6 +14,7 @@ import static org.tron.core.services.jsonrpc.JsonRpcApiUtil.parseBlockNumber; import static org.tron.core.services.jsonrpc.JsonRpcApiUtil.triggerCallContract; +import com.google.common.annotations.VisibleForTesting; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import com.google.protobuf.ByteString; @@ -30,6 +31,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; +import java.util.concurrent.ForkJoinPool; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; import lombok.Getter; @@ -118,7 +120,8 @@ public enum RequestSource { private static final String FILTER_NOT_FOUND = "filter not found"; public static final int EXPIRE_SECONDS = 5 * 60; - private static final int maxBlockFilterNum = Args.getInstance().getJsonRpcMaxBlockFilterNum(); + private final int maxBlockFilterNum = Args.getInstance().getJsonRpcMaxBlockFilterNum(); + private final int maxLogFilterNum = Args.getInstance().getJsonRpcMaxLogFilterNum(); private static final Cache logElementCache = CacheBuilder.newBuilder() .maximumSize(300_000L) // 300s * tps(1000) * 1 log/tx ≈ 300_000 @@ -133,25 +136,25 @@ public enum RequestSource { * for log filter in Full Json-RPC */ @Getter - private static final Map eventFilter2ResultFull = + private final Map eventFilter2ResultFull = new ConcurrentHashMap<>(); /** * for block in Full Json-RPC */ @Getter - private static final Map blockFilter2ResultFull = + private final Map blockFilter2ResultFull = new ConcurrentHashMap<>(); /** * for log filter in solidity Json-RPC */ @Getter - private static final Map eventFilter2ResultSolidity = + private final Map eventFilter2ResultSolidity = new ConcurrentHashMap<>(); /** * for block in solidity Json-RPC */ @Getter - private static final Map blockFilter2ResultSolidity = + private final Map blockFilter2ResultSolidity = new ConcurrentHashMap<>(); public static final String HASH_REGEX = "(0x)?[a-zA-Z0-9]{64}$"; @@ -169,25 +172,42 @@ public enum RequestSource { private static final String ERROR_SELECTOR = "08c379a0"; // Function selector for Error(string) private static final int REVERT_REASON_SELECTOR_LENGTH = 4; private static final int MAX_REVERT_REASON_PAYLOAD_BYTES = 4096; + private int filterParallelThreshold = 10000; + /** + * Using the default maxLogFilterNum of 20,000, a 3-thread pool can keep up with log event + * processing for each block within the 3-second BLOCK_PRODUCED_INTERVAL. Increasing the thread + * pool size too much may affect the performance of the main block processing thread. + */ + private final ForkJoinPool logsFilterPool = + ExecutorServiceManager.newForkJoinPool("logs-filter-pool", 3); /** * thread pool of query section bloom store */ private final ExecutorService sectionExecutor; private final NodeInfoService nodeInfoService; private final Wallet wallet; - private final Manager manager; + @Autowired + private Manager manager; private final String esName = "query-section"; @Autowired - public TronJsonRpcImpl(@Autowired NodeInfoService nodeInfoService, @Autowired Wallet wallet, - @Autowired Manager manager) { + public TronJsonRpcImpl(@Autowired NodeInfoService nodeInfoService, @Autowired Wallet wallet) { this.nodeInfoService = nodeInfoService; this.wallet = wallet; - this.manager = manager; this.sectionExecutor = ExecutorServiceManager.newFixedThreadPool(esName, 5); } - public static void handleBLockFilter(BlockFilterCapsule blockFilterCapsule) { + @VisibleForTesting + public void setManager(Manager manager) { + this.manager = manager; + } + + @VisibleForTesting + public void setFilterParallelThreshold(int filterParallelThreshold) { + this.filterParallelThreshold = filterParallelThreshold; + } + + public void handleBLockFilter(BlockFilterCapsule blockFilterCapsule) { Iterator> it; if (blockFilterCapsule.isSolidified()) { @@ -221,54 +241,69 @@ public static void handleBLockFilter(BlockFilterCapsule blockFilterCapsule) { /** * append LogsFilterCapsule's LogFilterElement list to each filter if matched */ - public static void handleLogsFilter(LogsFilterCapsule logsFilterCapsule) { - Iterator> it; + public void handleLogsFilter(LogsFilterCapsule logsFilterCapsule) { + long t1 = System.currentTimeMillis(); + Map eventFilterMap; if (logsFilterCapsule.isSolidified()) { - it = getEventFilter2ResultSolidity().entrySet().iterator(); + eventFilterMap = getEventFilter2ResultSolidity(); } else { - it = getEventFilter2ResultFull().entrySet().iterator(); + eventFilterMap = getEventFilter2ResultFull(); } - while (it.hasNext()) { - Entry entry = it.next(); - if (entry.getValue().isExpire()) { - it.remove(); - continue; - } - - LogFilterAndResult logFilterAndResult = entry.getValue(); - long fromBlock = logFilterAndResult.getLogFilterWrapper().getFromBlock(); - long toBlock = logFilterAndResult.getLogFilterWrapper().getToBlock(); - if (!(fromBlock <= logsFilterCapsule.getBlockNumber() - && logsFilterCapsule.getBlockNumber() <= toBlock)) { - continue; - } + if (eventFilterMap.size() <= filterParallelThreshold) { + eventFilterMap.entrySet().forEach( + entry -> processLogFilterEntry(entry, eventFilterMap, logsFilterCapsule)); + } else { + logsFilterPool.submit(() -> eventFilterMap.entrySet().parallelStream() + .forEach(entry -> processLogFilterEntry(entry, eventFilterMap, logsFilterCapsule)) + ).join(); + } + long t2 = System.currentTimeMillis(); + logger.debug("handleLogsFilter {} cost {}, filter size {}", + logsFilterCapsule.isSolidified() ? "Solidity" : "Full", t2 - t1, eventFilterMap.size()); + } + + private void processLogFilterEntry( + Map.Entry entry, + Map eventFilterMap, + LogsFilterCapsule logsFilterCapsule) { + LogFilterAndResult logFilterAndResult = entry.getValue(); + if (logFilterAndResult.isExpire()) { + eventFilterMap.remove(entry.getKey()); + return; + } - if (logsFilterCapsule.getBloom() != null - && !logFilterAndResult.getLogFilterWrapper().getLogFilter() - .matchBloom(logsFilterCapsule.getBloom())) { - continue; - } + long blockNumber = logsFilterCapsule.getBlockNumber(); + long fromBlock = logFilterAndResult.getLogFilterWrapper().getFromBlock(); + long toBlock = logFilterAndResult.getLogFilterWrapper().getToBlock(); + if (!(fromBlock <= blockNumber && blockNumber <= toBlock)) { + return; + } - LogFilter logFilter = logFilterAndResult.getLogFilterWrapper().getLogFilter(); - List elements = - LogMatch.matchBlock(logFilter, logsFilterCapsule.getBlockNumber(), - logsFilterCapsule.getBlockHash(), logsFilterCapsule.getTxInfoList(), - logsFilterCapsule.isRemoved()); + if (logsFilterCapsule.getBloom() != null && !logFilterAndResult.getLogFilterWrapper() + .getLogFilter().matchBloom(logsFilterCapsule.getBloom())) { + return; + } - for (LogFilterElement element : elements) { - LogFilterElement cachedElement; - try { - // compare with hashcode() first, then with equals(). If not exist, put it. - cachedElement = logElementCache.get(element, () -> element); - } catch (ExecutionException e) { - logger.error("Getting/loading LogFilterElement from cache fails", e); // never happen - cachedElement = element; - } - logFilterAndResult.getResult().add(cachedElement); + LogFilter logFilter = logFilterAndResult.getLogFilterWrapper().getLogFilter(); + List elements = + LogMatch.matchBlock(logFilter, blockNumber, logsFilterCapsule.getBlockHash(), + logsFilterCapsule.getTxInfoList(), logsFilterCapsule.isRemoved()); + + List localResults = new ArrayList<>(elements.size()); + for (LogFilterElement element : elements) { + LogFilterElement cachedElement; + try { + // compare with hashcode() first, then with equals(). If not exist, put it. + cachedElement = logElementCache.get(element, () -> element); + } catch (ExecutionException e) { + logger.error("Getting/loading LogFilterElement from cache fails", e); // never happen + cachedElement = element; } + localResults.add(cachedElement); } + logFilterAndResult.getResult().addAll(localResults); } @Override @@ -1399,7 +1434,7 @@ public CompilationResult ethSubmitHashrate(String hashrate, String id) @Override public String newFilter(FilterRequest fr) throws JsonRpcInvalidParamsException, - JsonRpcMethodNotFoundException { + JsonRpcMethodNotFoundException, JsonRpcExceedLimitException { disableInPBFT("eth_newFilter"); // not supports finalized as block parameter @@ -1414,7 +1449,11 @@ public String newFilter(FilterRequest fr) throws JsonRpcInvalidParamsException, } else { eventFilter2Result = eventFilter2ResultSolidity; } - + // Due to concurrent access, the threshold may occasionally be exceeded. + if (maxLogFilterNum > 0 && eventFilter2Result.size() >= maxLogFilterNum) { + throw new JsonRpcExceedLimitException( + "exceed max log filters: " + maxLogFilterNum + ", try again later"); + } long currentMaxFullNum = wallet.getNowBlock().getBlockHeader().getRawData().getNumber(); LogFilterAndResult logFilterAndResult = new LogFilterAndResult(fr, currentMaxFullNum, wallet); String filterID = generateFilterId(); @@ -1433,7 +1472,7 @@ public String newBlockFilter() throws JsonRpcMethodNotFoundException, } else { blockFilter2Result = blockFilter2ResultSolidity; } - if (blockFilter2Result.size() >= maxBlockFilterNum) { + if (maxBlockFilterNum > 0 && blockFilter2Result.size() >= maxBlockFilterNum) { throw new JsonRpcExceedLimitException( "exceed max block filters: " + maxBlockFilterNum + ", try again later"); } @@ -1542,7 +1581,7 @@ private LogFilterElement[] getLogsByLogFilterWrapper(LogFilterWrapper logFilterW return logMatch.matchBlockOneByOne(); } - public static Object[] getFilterResult(String filterId, Map + public Object[] getFilterResult(String filterId, Map blockFilter2Result, Map eventFilter2Result) throws ItemNotFoundException { Object[] result; @@ -1566,6 +1605,7 @@ public static Object[] getFilterResult(String filterId, Map matchedLog = matchBlock(logFilterWrapper.getLogFilter(), blockNum, blockHash, transactionInfoList, false); + if (!matchedLog.isEmpty()) { + if (logFilterElementList.size() + matchedLog.size() > LogBlockQuery.MAX_RESULT) { + throw new JsonRpcTooManyResultException( + "query returned more than " + LogBlockQuery.MAX_RESULT + " results"); + } logFilterElementList.addAll(matchedLog); } - - if (logFilterElementList.size() > LogBlockQuery.MAX_RESULT) { - throw new JsonRpcTooManyResultException( - "query returned more than " + LogBlockQuery.MAX_RESULT + " results"); - } } return logFilterElementList.toArray(new LogFilterElement[0]); diff --git a/framework/src/main/resources/config.conf b/framework/src/main/resources/config.conf index 7a395e88159..4c26cb90fba 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -395,8 +395,10 @@ node { # The maximum number of allowed topics within a topic criteria, default value is 1000, # should be > 0, otherwise means no limit. maxSubTopics = 1000 - # Allowed maximum number for blockFilter + # Allowed maximum number for blockFilter, >0 otherwise no limit maxBlockFilterNum = 50000 + # Allowed maximum number for newFilter, >0 otherwise no limit + maxLogFilterNum = 20000 } # Disabled api list, it will work for http, rpc and pbft, both FullNode and SolidityNode, diff --git a/framework/src/test/java/org/tron/common/logsfilter/FilterQueryTest.java b/framework/src/test/java/org/tron/common/logsfilter/FilterQueryTest.java index c87d8e1136e..b57b3a92fcd 100644 --- a/framework/src/test/java/org/tron/common/logsfilter/FilterQueryTest.java +++ b/framework/src/test/java/org/tron/common/logsfilter/FilterQueryTest.java @@ -21,7 +21,6 @@ import org.junit.Assert; import org.junit.Test; import org.tron.common.logsfilter.capsule.ContractEventTriggerCapsule; -import org.tron.common.logsfilter.capsule.FilterTriggerCapsule; import org.tron.common.logsfilter.capsule.TriggerCapsule; import org.tron.common.runtime.LogEventWrapper; import org.tron.protos.contract.SmartContractOuterClass.SmartContract.ABI.Entry; @@ -103,13 +102,6 @@ public synchronized void testMatchFilter() { assertNotNull(filterQuery.toString()); } - FilterTriggerCapsule filterTriggerCapsule = new FilterTriggerCapsule(); - try { - filterTriggerCapsule.processFilterTrigger(); - } catch (Exception e) { - logger.info(e.getMessage()); - } - TriggerCapsule triggerCapsule = new TriggerCapsule(); try { triggerCapsule.processTrigger(); diff --git a/framework/src/test/java/org/tron/common/logsfilter/capsule/BlockFilterCapsuleTest.java b/framework/src/test/java/org/tron/common/logsfilter/capsule/BlockFilterCapsuleTest.java index b5f7e676eea..aac42facf96 100644 --- a/framework/src/test/java/org/tron/common/logsfilter/capsule/BlockFilterCapsuleTest.java +++ b/framework/src/test/java/org/tron/common/logsfilter/capsule/BlockFilterCapsuleTest.java @@ -31,7 +31,6 @@ public void testSetAndIsSolidified() { blockFilterCapsule = new BlockFilterCapsule( "e58f33f9baf9305dc6f82b9f1934ea8f0ade2defb951258d50167028c780351f", false); blockFilterCapsule.setSolidified(true); - blockFilterCapsule.processFilterTrigger(); Assert.assertTrue(blockFilterCapsule.isSolidified()); } } diff --git a/framework/src/test/java/org/tron/common/logsfilter/capsule/LogsFilterCapsuleTest.java b/framework/src/test/java/org/tron/common/logsfilter/capsule/LogsFilterCapsuleTest.java index 691a3106b49..f23c446c23d 100644 --- a/framework/src/test/java/org/tron/common/logsfilter/capsule/LogsFilterCapsuleTest.java +++ b/framework/src/test/java/org/tron/common/logsfilter/capsule/LogsFilterCapsuleTest.java @@ -27,7 +27,6 @@ public void testSetAndGetLogsFilterCapsule() { capsule.setRemoved(capsule.isRemoved()); capsule.setTxInfoList(capsule.getTxInfoList()); assertNotNull(capsule.toString()); - capsule.processFilterTrigger(); } } diff --git a/framework/src/test/java/org/tron/common/runtime/vm/Create2Test.java b/framework/src/test/java/org/tron/common/runtime/vm/Create2Test.java index 6fa2801c51f..5a58407f887 100644 --- a/framework/src/test/java/org/tron/common/runtime/vm/Create2Test.java +++ b/framework/src/test/java/org/tron/common/runtime/vm/Create2Test.java @@ -209,7 +209,8 @@ private void testJsonRpc(byte[] actualContract, long loop) { NodeInfoService nodeInfoService; nodeInfoService = context.getBean(NodeInfoService.class); Wallet wallet = context.getBean(Wallet.class); - tronJsonRpc = new TronJsonRpcImpl(nodeInfoService, wallet, manager); + tronJsonRpc = new TronJsonRpcImpl(nodeInfoService, wallet); + tronJsonRpc.setManager(manager); try { String res = tronJsonRpc.getStorageAt(ByteArray.toHexString(actualContract), "0", "latest"); diff --git a/framework/src/test/java/org/tron/core/jsonrpc/ConcurrentHashMapTest.java b/framework/src/test/java/org/tron/core/jsonrpc/ConcurrentHashMapTest.java index 2cdcaaf7a53..2fcb624002e 100644 --- a/framework/src/test/java/org/tron/core/jsonrpc/ConcurrentHashMapTest.java +++ b/framework/src/test/java/org/tron/core/jsonrpc/ConcurrentHashMapTest.java @@ -23,6 +23,7 @@ @Slf4j public class ConcurrentHashMapTest { private static final String EXECUTOR_NAME = "jsonrpc-concurrent-map-test"; + private final TronJsonRpcImpl jsonRpc = new TronJsonRpcImpl(null, null); private static int randomInt(int minInt, int maxInt) { return (int) round(random(true) * (maxInt - minInt) + minInt, true); @@ -39,7 +40,7 @@ public void testHandleBlockHash() { int times = 100; int eachCount = 200; - Map conMap = TronJsonRpcImpl.getBlockFilter2ResultFull(); + Map conMap = jsonRpc.getBlockFilter2ResultFull(); Map> resultMap1 = new ConcurrentHashMap<>(); // used to check result Map> resultMap2 = new ConcurrentHashMap<>(); // used to check result Map> resultMap3 = new ConcurrentHashMap<>(); // used to check result @@ -71,7 +72,7 @@ public void testHandleBlockHash() { for (int j = 1 + (i - 1) * eachCount; j <= i * eachCount; j++) { BlockFilterCapsule blockFilterCapsule = new BlockFilterCapsule(String.valueOf(j), false); - TronJsonRpcImpl.handleBLockFilter(blockFilterCapsule); + jsonRpc.handleBLockFilter(blockFilterCapsule); } try { Thread.sleep(randomInt(50, 100)); @@ -96,8 +97,8 @@ public void testHandleBlockHash() { for (int k = 0; k < 5; k++) { try { - Object[] blockHashList = TronJsonRpcImpl.getFilterResult(String.valueOf(k), conMap, - TronJsonRpcImpl.getEventFilter2ResultFull()); + Object[] blockHashList = jsonRpc.getFilterResult(String.valueOf(k), conMap, + jsonRpc.getEventFilter2ResultFull()); for (Object str : blockHashList) { resultMap1.get(String.valueOf(k)).add(str.toString()); @@ -124,8 +125,8 @@ public void testHandleBlockHash() { for (int k = 0; k < 5; k++) { try { - Object[] blockHashList = TronJsonRpcImpl.getFilterResult(String.valueOf(k), conMap, - TronJsonRpcImpl.getEventFilter2ResultFull()); + Object[] blockHashList = jsonRpc.getFilterResult(String.valueOf(k), conMap, + jsonRpc.getEventFilter2ResultFull()); // if (blockHashList.length == 0) { // continue; @@ -156,8 +157,8 @@ public void testHandleBlockHash() { for (int k = 0; k < 5; k++) { try { - Object[] blockHashList = TronJsonRpcImpl.getFilterResult(String.valueOf(k), conMap, - TronJsonRpcImpl.getEventFilter2ResultFull()); + Object[] blockHashList = jsonRpc.getFilterResult(String.valueOf(k), conMap, + jsonRpc.getEventFilter2ResultFull()); for (Object str : blockHashList) { try { diff --git a/framework/src/test/java/org/tron/core/jsonrpc/HandleLogsFilterTest.java b/framework/src/test/java/org/tron/core/jsonrpc/HandleLogsFilterTest.java new file mode 100644 index 00000000000..33835c482fe --- /dev/null +++ b/framework/src/test/java/org/tron/core/jsonrpc/HandleLogsFilterTest.java @@ -0,0 +1,293 @@ +package org.tron.core.jsonrpc; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.tron.common.logsfilter.capsule.LogsFilterCapsule; +import org.tron.common.runtime.vm.DataWord; +import org.tron.common.runtime.vm.LogInfo; +import org.tron.core.exception.jsonrpc.JsonRpcInvalidParamsException; +import org.tron.core.services.jsonrpc.TronJsonRpc.FilterRequest; +import org.tron.core.services.jsonrpc.TronJsonRpcImpl; +import org.tron.core.services.jsonrpc.filters.FilterResult; +import org.tron.core.services.jsonrpc.filters.LogFilterAndResult; +import org.tron.protos.Protocol.TransactionInfo; + +public class HandleLogsFilterTest { + + private static final String FILTER_ID_1 = "handle-logs-test-001"; + private static final String FILTER_ID_2 = "handle-logs-test-002"; + + private TronJsonRpcImpl jsonRpc; + + @Before + public void setUp() { + jsonRpc = new TronJsonRpcImpl(null, null); + } + + @After + public void tearDown() throws Exception { + jsonRpc.close(); + } + + private TransactionInfo buildTxInfoWithLog(byte[] address) { + LogInfo logInfo = new LogInfo(address, + Collections.singletonList(new DataWord(new byte[32])), new byte[0]); + return TransactionInfo.newBuilder().addLog(LogInfo.buildLog(logInfo)).build(); + } + + /** + * Events dispatched to a matching filter in the serial (<=10000 entries) path. + */ + @Test + public void testMatchingFilter_receivesLogElements() throws JsonRpcInvalidParamsException { + FilterRequest fr = new FilterRequest(); + LogFilterAndResult filterAndResult = new LogFilterAndResult(fr, 100L, null); + jsonRpc.getEventFilter2ResultFull().put(FILTER_ID_1, filterAndResult); + + List txInfoList = + Collections.singletonList(buildTxInfoWithLog(new byte[20])); + LogsFilterCapsule capsule = + new LogsFilterCapsule(150L, "0xabcdef", null, txInfoList, false, false); + + jsonRpc.handleLogsFilter(capsule); + + Assert.assertEquals(1, filterAndResult.getResult().size()); + } + + /** + * Filter with fromBlock=100 does not receive a capsule whose blockNumber is 50. + */ + @Test + public void testBlockNumberBelowRange_noResult() throws JsonRpcInvalidParamsException { + FilterRequest fr = new FilterRequest(); + // currentMaxBlockNum=100 → fromBlock=100, toBlock=MAX_VALUE + LogFilterAndResult filterAndResult = new LogFilterAndResult(fr, 100L, null); + jsonRpc.getEventFilter2ResultFull().put(FILTER_ID_1, filterAndResult); + + List txInfoList = + Collections.singletonList(buildTxInfoWithLog(new byte[20])); + LogsFilterCapsule capsule = + new LogsFilterCapsule(50L, "0xabcdef", null, txInfoList, false, false); + + jsonRpc.handleLogsFilter(capsule); + + Assert.assertTrue(filterAndResult.getResult().isEmpty()); + } + + /** + * An expired filter is removed from the map during handleLogsFilter. + */ + @Test + public void testExpiredFilter_removedFromMap() throws Exception { + FilterRequest fr = new FilterRequest(); + LogFilterAndResult filterAndResult = new LogFilterAndResult(fr, 100L, null); + + Field expireField = FilterResult.class.getDeclaredField("expireTimeStamp"); + expireField.setAccessible(true); + expireField.setLong(filterAndResult, 0L); + + Map map = jsonRpc.getEventFilter2ResultFull(); + map.put(FILTER_ID_1, filterAndResult); + Assert.assertTrue(map.containsKey(FILTER_ID_1)); + + List txInfoList = + Collections.singletonList(buildTxInfoWithLog(new byte[20])); + LogsFilterCapsule capsule = + new LogsFilterCapsule(150L, "0xabcdef", null, txInfoList, false, false); + + jsonRpc.handleLogsFilter(capsule); + + Assert.assertFalse("expired filter should be removed", map.containsKey(FILTER_ID_1)); + } + + /** + * A solidified capsule is routed only to the solidity map; the full-node map is untouched. + */ + @Test + public void testSolidifiedCapsule_routedToSolidityMap() throws JsonRpcInvalidParamsException { + FilterRequest fr = new FilterRequest(); + LogFilterAndResult solidityFilter = new LogFilterAndResult(fr, 100L, null); + jsonRpc.getEventFilter2ResultSolidity().put(FILTER_ID_1, solidityFilter); + + LogFilterAndResult fullFilter = new LogFilterAndResult(fr, 100L, null); + jsonRpc.getEventFilter2ResultFull().put(FILTER_ID_2, fullFilter); + + List txInfoList = + Collections.singletonList(buildTxInfoWithLog(new byte[20])); + LogsFilterCapsule capsule = + new LogsFilterCapsule(150L, "0xabcdef", null, txInfoList, true, false); + + jsonRpc.handleLogsFilter(capsule); + + Assert.assertEquals(1, solidityFilter.getResult().size()); + Assert.assertTrue("full-node filter must not be touched", fullFilter.getResult().isEmpty()); + } + + /** + * A non-solidified capsule is routed only to the full-node map. + */ + @Test + public void testNonSolidifiedCapsule_routedToFullMap() throws JsonRpcInvalidParamsException { + FilterRequest fr = new FilterRequest(); + LogFilterAndResult solidityFilter = new LogFilterAndResult(fr, 100L, null); + jsonRpc.getEventFilter2ResultSolidity().put(FILTER_ID_1, solidityFilter); + + LogFilterAndResult fullFilter = new LogFilterAndResult(fr, 100L, null); + jsonRpc.getEventFilter2ResultFull().put(FILTER_ID_2, fullFilter); + + List txInfoList = + Collections.singletonList(buildTxInfoWithLog(new byte[20])); + LogsFilterCapsule capsule = + new LogsFilterCapsule(150L, "0xabcdef", null, txInfoList, false, false); + + jsonRpc.handleLogsFilter(capsule); + + Assert.assertEquals(1, fullFilter.getResult().size()); + Assert.assertTrue("solidity filter must not be touched", solidityFilter.getResult().isEmpty()); + } + + /** + * Both filters in the map receive events when both match. + */ + @Test + public void testMultipleMatchingFilters_bothReceiveEvents() throws JsonRpcInvalidParamsException { + FilterRequest fr = new FilterRequest(); + LogFilterAndResult filter1 = new LogFilterAndResult(fr, 100L, null); + LogFilterAndResult filter2 = new LogFilterAndResult(fr, 100L, null); + jsonRpc.getEventFilter2ResultFull().put(FILTER_ID_1, filter1); + jsonRpc.getEventFilter2ResultFull().put(FILTER_ID_2, filter2); + + List txInfoList = + Collections.singletonList(buildTxInfoWithLog(new byte[20])); + LogsFilterCapsule capsule = + new LogsFilterCapsule(150L, "0xabcdef", null, txInfoList, false, false); + + jsonRpc.handleLogsFilter(capsule); + + Assert.assertEquals(1, filter1.getResult().size()); + Assert.assertEquals(1, filter2.getResult().size()); + } + + /** + * An empty txInfoList produces no results. + */ + @Test + public void testEmptyTxInfoList_noResult() throws JsonRpcInvalidParamsException { + FilterRequest fr = new FilterRequest(); + LogFilterAndResult filterAndResult = new LogFilterAndResult(fr, 100L, null); + jsonRpc.getEventFilter2ResultFull().put(FILTER_ID_1, filterAndResult); + + LogsFilterCapsule capsule = new LogsFilterCapsule(150L, "0xabcdef", null, + Collections.emptyList(), false, false); + + jsonRpc.handleLogsFilter(capsule); + + Assert.assertTrue(filterAndResult.getResult().isEmpty()); + } + + private void setParallelThreshold(int value) { + jsonRpc.setFilterParallelThreshold(value); + } + + /** + * Parallel path: every matching filter receives exactly one event — no events dropped or + * double-counted under concurrent dispatch. + */ + @Test(timeout = 10000) + public void testParallelPath_allMatchingFilters_receiveEvents() throws Exception { + setParallelThreshold(2); + int count = 5; + FilterRequest fr = new FilterRequest(); + List txInfoList = + Collections.singletonList(buildTxInfoWithLog(new byte[20])); + Map map = jsonRpc.getEventFilter2ResultFull(); + String prefix = "parallel-match-"; + for (int i = 0; i < count; i++) { + map.put(prefix + i, new LogFilterAndResult(fr, 0L, null)); + } + + LogsFilterCapsule capsule = + new LogsFilterCapsule(150L, "0xabcdef", null, txInfoList, false, false); + jsonRpc.handleLogsFilter(capsule); + + for (int i = 0; i < count; i++) { + Assert.assertEquals("filter " + i + " must receive exactly one event", + 1, map.get(prefix + i).getResult().size()); + } + } + + /** + * Parallel path: expired filters are evicted and all valid filters still receive their events. + */ + @Test(timeout = 10000) + public void testParallelPath_expiredFiltersRemoved() throws Exception { + setParallelThreshold(2); + int expiredCount = 2; + int validCount = 3; + FilterRequest fr = new FilterRequest(); + Field expireField = FilterResult.class.getDeclaredField("expireTimeStamp"); + expireField.setAccessible(true); + Map map = jsonRpc.getEventFilter2ResultFull(); + String prefix = "parallel-expire-"; + for (int i = 0; i < expiredCount + validCount; i++) { + LogFilterAndResult filter = new LogFilterAndResult(fr, 0L, null); + if (i < expiredCount) { + expireField.setLong(filter, 0L); + } + map.put(prefix + i, filter); + } + + List txInfoList = + Collections.singletonList(buildTxInfoWithLog(new byte[20])); + LogsFilterCapsule capsule = + new LogsFilterCapsule(150L, "0xabcdef", null, txInfoList, false, false); + jsonRpc.handleLogsFilter(capsule); + + for (int i = 0; i < expiredCount; i++) { + Assert.assertFalse("expired filter " + i + " should be removed", + map.containsKey(prefix + i)); + } + for (int i = expiredCount; i < expiredCount + validCount; i++) { + Assert.assertEquals("valid filter " + i + " must receive one event", + 1, map.get(prefix + i).getResult().size()); + } + } + + /** + * Parallel path: a solidified capsule dispatches only to the solidity map; the full-node map + * is untouched even though it holds entries. + */ + @Test(timeout = 10000) + public void testParallelPath_solidifiedCapsule_routedToSolidityMap() throws Exception { + setParallelThreshold(2); + int count = 5; + FilterRequest fr = new FilterRequest(); + List txInfoList = + Collections.singletonList(buildTxInfoWithLog(new byte[20])); + Map solidityMap = jsonRpc.getEventFilter2ResultSolidity(); + Map fullMap = jsonRpc.getEventFilter2ResultFull(); + String solidityPrefix = "parallel-solid-"; + for (int i = 0; i < count; i++) { + solidityMap.put(solidityPrefix + i, new LogFilterAndResult(fr, 0L, null)); + } + LogFilterAndResult fullFilter = new LogFilterAndResult(fr, 0L, null); + fullMap.put("parallel-solid-full-0", fullFilter); + + LogsFilterCapsule capsule = + new LogsFilterCapsule(150L, "0xabcdef", null, txInfoList, true, false); + jsonRpc.handleLogsFilter(capsule); + + for (int i = 0; i < count; i++) { + Assert.assertEquals("solidity filter " + i + " must receive one event", + 1, solidityMap.get(solidityPrefix + i).getResult().size()); + } + Assert.assertTrue("full-map filter must not receive events", + fullFilter.getResult().isEmpty()); + } +} diff --git a/framework/src/test/java/org/tron/core/jsonrpc/JsonRpcCallAndEstimateGasTest.java b/framework/src/test/java/org/tron/core/jsonrpc/JsonRpcCallAndEstimateGasTest.java index 65defdab2ed..2ab455fa580 100644 --- a/framework/src/test/java/org/tron/core/jsonrpc/JsonRpcCallAndEstimateGasTest.java +++ b/framework/src/test/java/org/tron/core/jsonrpc/JsonRpcCallAndEstimateGasTest.java @@ -211,7 +211,9 @@ private static TronJsonRpcImpl newRpcWithMockedFailedCall(byte[] resData, Estima }); } - return new TronJsonRpcImpl(mockNodeInfo, mockWallet, mockManager); + TronJsonRpcImpl rpc = new TronJsonRpcImpl(mockNodeInfo, mockWallet); + rpc.setManager(mockManager); + return rpc; } private static TronJsonRpcImpl newRpcWithMockedSuccessfulCall(byte[]... constantResults) @@ -237,7 +239,9 @@ private static TronJsonRpcImpl newRpcWithMockedSuccessfulCall(byte[]... constant .build(); }); - return new TronJsonRpcImpl(mockNodeInfo, mockWallet, mockManager); + TronJsonRpcImpl rpc = new TronJsonRpcImpl(mockNodeInfo, mockWallet); + rpc.setManager(mockManager); + return rpc; } private static TronJsonRpcImpl newRpcWithMockedEstimateGasSuccessfulCall(long energyValue, @@ -272,6 +276,8 @@ private static TronJsonRpcImpl newRpcWithMockedEstimateGasSuccessfulCall(long en }); } - return new TronJsonRpcImpl(mockNodeInfo, mockWallet, mockManager); + TronJsonRpcImpl rpc = new TronJsonRpcImpl(mockNodeInfo, mockWallet); + rpc.setManager(mockManager); + return rpc; } } diff --git a/framework/src/test/java/org/tron/core/jsonrpc/JsonrpcServiceTest.java b/framework/src/test/java/org/tron/core/jsonrpc/JsonrpcServiceTest.java index 9a1641f9e45..f753045d259 100644 --- a/framework/src/test/java/org/tron/core/jsonrpc/JsonrpcServiceTest.java +++ b/framework/src/test/java/org/tron/core/jsonrpc/JsonrpcServiceTest.java @@ -214,7 +214,8 @@ public void init() { dbManager.getTransactionRetStore() .put(ByteArray.fromLong(blockCapsule2.getNum()), transactionRetCapsule2); - tronJsonRpc = new TronJsonRpcImpl(nodeInfoService, wallet, dbManager); + tronJsonRpc = new TronJsonRpcImpl(nodeInfoService, wallet); + tronJsonRpc.setManager(dbManager); } @Test diff --git a/framework/src/test/java/org/tron/core/jsonrpc/LogMatchOverLimitTest.java b/framework/src/test/java/org/tron/core/jsonrpc/LogMatchOverLimitTest.java new file mode 100644 index 00000000000..77f869fd5a8 --- /dev/null +++ b/framework/src/test/java/org/tron/core/jsonrpc/LogMatchOverLimitTest.java @@ -0,0 +1,151 @@ +package org.tron.core.jsonrpc; + +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.protobuf.ByteString; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Assert; +import org.junit.Test; +import org.tron.api.GrpcAPI.TransactionInfoList; +import org.tron.common.utils.Sha256Hash; +import org.tron.core.ChainBaseManager; +import org.tron.core.capsule.BlockCapsule; +import org.tron.core.db.Manager; +import org.tron.core.exception.BadItemException; +import org.tron.core.exception.ItemNotFoundException; +import org.tron.core.exception.jsonrpc.JsonRpcInvalidParamsException; +import org.tron.core.exception.jsonrpc.JsonRpcTooManyResultException; +import org.tron.core.services.jsonrpc.TronJsonRpc.FilterRequest; +import org.tron.core.services.jsonrpc.TronJsonRpc.LogFilterElement; +import org.tron.core.services.jsonrpc.filters.LogBlockQuery; +import org.tron.core.services.jsonrpc.filters.LogFilterWrapper; +import org.tron.core.services.jsonrpc.filters.LogMatch; +import org.tron.protos.Protocol.TransactionInfo; +import org.tron.protos.Protocol.TransactionInfo.Log; + +/** + * Verifies the over-limit check in {@link LogMatch#matchBlockOneByOne()} + * The fix ensures the exception is thrown BEFORE {@code addAll}, so the result list never + * silently exceeds {@link LogBlockQuery#MAX_RESULT}. + */ +public class LogMatchOverLimitTest { + + private static final int MAX_RESULT = LogBlockQuery.MAX_RESULT; // 10000 + + /** Builds a TransactionInfoList containing one TransactionInfo with {@code logCount} logs. */ + private TransactionInfoList buildTxList(int logCount) { + TransactionInfo.Builder txBuilder = TransactionInfo.newBuilder(); + for (int i = 0; i < logCount; i++) { + txBuilder.addLog(Log.newBuilder() + .setAddress(ByteString.copyFrom(new byte[20])) + .build()); + } + return TransactionInfoList.newBuilder() + .addTransactionInfo(txBuilder.build()) + .build(); + } + + private Manager buildMockManager(long blockNum, TransactionInfoList txList) + throws ItemNotFoundException { + Manager manager = mock(Manager.class); + ChainBaseManager chainBaseManager = mock(ChainBaseManager.class); + BlockCapsule.BlockId blockId = new BlockCapsule.BlockId(Sha256Hash.ZERO_HASH, blockNum); + + when(manager.getChainBaseManager()).thenReturn(chainBaseManager); + when(chainBaseManager.getBlockIdByNum(anyLong())).thenReturn(blockId); + when(manager.getTransactionInfoByBlockNum(blockNum)).thenReturn(txList); + return manager; + } + + private Manager buildMockManager(long block1, TransactionInfoList txList1, + long block2, TransactionInfoList txList2) throws ItemNotFoundException { + Manager manager = mock(Manager.class); + ChainBaseManager chainBaseManager = mock(ChainBaseManager.class); + BlockCapsule.BlockId blockId = new BlockCapsule.BlockId(Sha256Hash.ZERO_HASH, 0); + + when(manager.getChainBaseManager()).thenReturn(chainBaseManager); + when(chainBaseManager.getBlockIdByNum(anyLong())).thenReturn(blockId); + when(manager.getTransactionInfoByBlockNum(block1)).thenReturn(txList1); + when(manager.getTransactionInfoByBlockNum(block2)).thenReturn(txList2); + return manager; + } + + private LogMatch buildLogMatch(List blockNums, Manager manager) + throws JsonRpcInvalidParamsException { + FilterRequest fr = new FilterRequest(); // match-all filter + LogFilterWrapper wrapper = new LogFilterWrapper(fr, 0L, null, false); + return new LogMatch(wrapper, blockNums, manager); + } + + /** Under the limit: all logs returned without exception. */ + @Test + public void testUnderLimit_returnsAllResults() + throws BadItemException, ItemNotFoundException, JsonRpcTooManyResultException, + JsonRpcInvalidParamsException { + int logCount = MAX_RESULT / 2; // 5000, well under limit + Manager manager = buildMockManager(100L, buildTxList(logCount)); + LogMatch logMatch = buildLogMatch(Collections.singletonList(100L), manager); + + LogFilterElement[] results = logMatch.matchBlockOneByOne(); + Assert.assertEquals(logCount, results.length); + } + + /** + * The cumulative log count from two blocks equals exactly MAX_RESULT. + * This should succeed (boundary: equal is still OK). + */ + @Test + public void testAtExactLimit_succeeds() + throws BadItemException, ItemNotFoundException, JsonRpcTooManyResultException, + JsonRpcInvalidParamsException { + // block 1: MAX_RESULT - 1 logs, block 2: 1 log → total == MAX_RESULT + Manager manager = buildMockManager( + 1L, buildTxList(MAX_RESULT - 1), + 2L, buildTxList(1)); + LogMatch logMatch = buildLogMatch(Arrays.asList(1L, 2L), manager); + + LogFilterElement[] results = logMatch.matchBlockOneByOne(); + Assert.assertEquals(MAX_RESULT, results.length); + } + + /** + * Verifies the fix: when the second block would push the total over MAX_RESULT, + * {@link JsonRpcTooManyResultException} is thrown BEFORE {@code addAll}. + */ + @Test + public void testExceedsLimit_throws() + throws ItemNotFoundException, JsonRpcInvalidParamsException { + // block 1: MAX_RESULT - 1 logs, block 2: 2 logs → 9999 + 2 = 10001 > MAX_RESULT + Manager manager = buildMockManager( + 1L, buildTxList(MAX_RESULT - 1), + 2L, buildTxList(2)); + LogMatch logMatch = buildLogMatch(Arrays.asList(1L, 2L), manager); + + assertThrows(JsonRpcTooManyResultException.class, logMatch::matchBlockOneByOne); + } + + /** A block with no matching logs is skipped without incrementing the result count. */ + @Test + public void testEmptyBlockSkipped() + throws BadItemException, ItemNotFoundException, JsonRpcTooManyResultException, + JsonRpcInvalidParamsException { + // block 1: no logs (empty txInfoList → skipped), block 2: 3 logs + Manager manager = mock(Manager.class); + ChainBaseManager chainBaseManager = mock(ChainBaseManager.class); + BlockCapsule.BlockId blockId = new BlockCapsule.BlockId(Sha256Hash.ZERO_HASH, 0); + when(manager.getChainBaseManager()).thenReturn(chainBaseManager); + when(chainBaseManager.getBlockIdByNum(anyLong())).thenReturn(blockId); + when(manager.getTransactionInfoByBlockNum(1L)) + .thenReturn(TransactionInfoList.newBuilder().build()); // empty + when(manager.getTransactionInfoByBlockNum(2L)).thenReturn(buildTxList(3)); + + LogMatch logMatch = buildLogMatch(Arrays.asList(1L, 2L), manager); + LogFilterElement[] results = logMatch.matchBlockOneByOne(); + Assert.assertEquals(3, results.length); + } +} diff --git a/framework/src/test/java/org/tron/core/jsonrpc/WalletCursorTest.java b/framework/src/test/java/org/tron/core/jsonrpc/WalletCursorTest.java index fdd9cb44222..24ca71a74bc 100644 --- a/framework/src/test/java/org/tron/core/jsonrpc/WalletCursorTest.java +++ b/framework/src/test/java/org/tron/core/jsonrpc/WalletCursorTest.java @@ -1,6 +1,9 @@ package org.tron.core.jsonrpc; import com.google.protobuf.ByteString; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import javax.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.junit.Assert; @@ -13,14 +16,18 @@ import org.tron.core.capsule.AccountCapsule; import org.tron.core.config.args.Args; import org.tron.core.db2.core.Chainbase.Cursor; +import org.tron.core.exception.jsonrpc.JsonRpcExceedLimitException; import org.tron.core.services.NodeInfoService; +import org.tron.core.services.jsonrpc.TronJsonRpc.FilterRequest; import org.tron.core.services.jsonrpc.TronJsonRpcImpl; import org.tron.core.services.jsonrpc.TronJsonRpcImpl.RequestSource; +import org.tron.core.services.jsonrpc.filters.LogFilterAndResult; import org.tron.core.services.jsonrpc.types.BuildArguments; import org.tron.protos.Protocol; @Slf4j public class WalletCursorTest extends BaseTest { + private static final String OWNER_ADDRESS; private static final String OWNER_ADDRESS_ACCOUNT_NAME = "first"; @Resource @@ -30,7 +37,7 @@ public class WalletCursorTest extends BaseTest { private static boolean init; static { - Args.setParam(new String[]{"--output-directory", dbPath()}, TestConstants.TEST_CONF); + Args.setParam(new String[] {"--output-directory", dbPath()}, TestConstants.TEST_CONF); OWNER_ADDRESS = Wallet.getAddressPreFixString() + "abd4b9367799eaa3197fecb144eb71de1e049abc"; @@ -53,7 +60,8 @@ public void init() { @Test public void testSource() { - TronJsonRpcImpl tronJsonRpc = new TronJsonRpcImpl(nodeInfoService, wallet, dbManager); + TronJsonRpcImpl tronJsonRpc = new TronJsonRpcImpl(nodeInfoService, wallet); + tronJsonRpc.setManager(dbManager); Assert.assertEquals(Cursor.HEAD, wallet.getCursor()); Assert.assertEquals(RequestSource.FULLNODE, tronJsonRpc.getSource()); @@ -84,9 +92,11 @@ public void testDisableInSolidity() { dbManager.setCursor(Cursor.SOLIDITY); - TronJsonRpcImpl tronJsonRpc = new TronJsonRpcImpl(nodeInfoService, wallet, dbManager); + TronJsonRpcImpl tronJsonRpc = new TronJsonRpcImpl(nodeInfoService, wallet); + tronJsonRpc.setManager(dbManager); try { tronJsonRpc.buildTransaction(buildArguments); + tronJsonRpc.close(); } catch (Exception e) { Assert.assertEquals("the method buildTransaction does not exist/is not available in " + "SOLIDITY", e.getMessage()); @@ -105,7 +115,8 @@ public void testDisableInPBFT() { dbManager.setCursor(Cursor.PBFT); - TronJsonRpcImpl tronJsonRpc = new TronJsonRpcImpl(nodeInfoService, wallet, dbManager); + TronJsonRpcImpl tronJsonRpc = new TronJsonRpcImpl(nodeInfoService, wallet); + tronJsonRpc.setManager(dbManager); try { tronJsonRpc.buildTransaction(buildArguments); } catch (Exception e) { @@ -132,13 +143,50 @@ public void testEnableInFullNode() { buildArguments.setTo("0x548794500882809695a8a687866e76d4271a1abc"); buildArguments.setValue("0x1f4"); - TronJsonRpcImpl tronJsonRpc = new TronJsonRpcImpl(nodeInfoService, wallet, dbManager); + TronJsonRpcImpl tronJsonRpc = new TronJsonRpcImpl(nodeInfoService, wallet); + tronJsonRpc.setManager(dbManager); try { tronJsonRpc.buildTransaction(buildArguments); + tronJsonRpc.close(); } catch (Exception e) { Assert.fail(); } } + /** + * When the active filter count reaches the configured cap (node.jsonrpc.maxLogFilterNum), + * eth_newFilter must throw JsonRpcExceedLimitException instead of growing without bound. + */ + @Test + public void testNewFilter_exceedsCapThrowsException() throws Exception { + int cap = 5; + int saved = Args.getInstance().getJsonRpcMaxLogFilterNum(); + Args.getInstance().setJsonRpcMaxLogFilterNum(cap); + FilterRequest fr = new FilterRequest(); + TronJsonRpcImpl tronJsonRpc = new TronJsonRpcImpl(nodeInfoService, wallet); + tronJsonRpc.setManager(dbManager); + Map map = tronJsonRpc.getEventFilter2ResultFull(); + List addedKeys = new ArrayList<>(); + + try { + for (int i = 0; i < cap; i++) { + String key = "walletcursor-cap-test-" + i; + map.put(key, new LogFilterAndResult(fr, 0L, null)); + addedKeys.add(key); + } + Assert.assertEquals(cap, addedKeys.size()); + + try { + tronJsonRpc.newFilter(fr); + Assert.fail("Expected JsonRpcExceedLimitException when filter count reaches cap"); + } catch (JsonRpcExceedLimitException e) { + Assert.assertTrue(e.getMessage().contains(String.valueOf(cap))); + } + } finally { + tronJsonRpc.close(); + Args.getInstance().setJsonRpcMaxLogFilterNum(saved); + } + } + } \ No newline at end of file diff --git a/framework/src/test/resources/config-localtest.conf b/framework/src/test/resources/config-localtest.conf index 53a78d3e4c6..d31705f39bd 100644 --- a/framework/src/test/resources/config-localtest.conf +++ b/framework/src/test/resources/config-localtest.conf @@ -168,6 +168,7 @@ node { # maxBlockRange = 5000 # maxSubTopics = 1000 # maxBlockFilterNum = 30000 + # maxLogFilterNum = 20000 } } diff --git a/framework/src/test/resources/config-test-mainnet.conf b/framework/src/test/resources/config-test-mainnet.conf index d39f432ac36..9f968c5628d 100644 --- a/framework/src/test/resources/config-test-mainnet.conf +++ b/framework/src/test/resources/config-test-mainnet.conf @@ -95,6 +95,7 @@ node { # maxBlockRange = 5000 # maxSubTopics = 1000 # maxBlockFilterNum = 50000 + # maxLogFilterNum = 20000 } rpc { diff --git a/framework/src/test/resources/config-test.conf b/framework/src/test/resources/config-test.conf index 71e93f84db5..21cebbfeef4 100644 --- a/framework/src/test/resources/config-test.conf +++ b/framework/src/test/resources/config-test.conf @@ -119,6 +119,7 @@ node { # maxBlockRange = 5000 # maxSubTopics = 1000 # maxBlockFilterNum = 30000 + # maxLogFilterNum = 20000 } # use your ipv6 address for node discovery and tcp connection, default false From 0a8289c481d5462c9def5d89cd83a0d8a890eaec Mon Sep 17 00:00:00 2001 From: 317787106 <317787106@qq.com> Date: Sat, 9 May 2026 23:54:31 +0800 Subject: [PATCH 3/3] feat(jsonrpc): add resource restrict for jsonrpc (#6728) --- .../common/parameter/CommonParameter.java | 10 +- .../org/tron/core/config/args/NodeConfig.java | 3 + common/src/main/resources/reference.conf | 18 +- framework/build.gradle | 1 + .../java/org/tron/core/config/args/Args.java | 3 + .../filter/BufferedResponseWrapper.java | 178 +++++++++++ .../filter/CachedBodyRequestWrapper.java | 97 ++++++ .../core/services/jsonrpc/JsonRpcServlet.java | 209 ++++++++++++- .../services/jsonrpc/filters/LogFilter.java | 4 + framework/src/main/resources/config.conf | 19 +- .../org/tron/core/jsonrpc/JsonRpcTest.java | 54 ++++ .../filter/BufferedResponseWrapperTest.java | 287 ++++++++++++++++++ .../filter/CachedBodyRequestWrapperTest.java | 109 +++++++ .../services/jsonrpc/JsonRpcServletTest.java | 264 ++++++++++++++++ 14 files changed, 1235 insertions(+), 21 deletions(-) create mode 100644 framework/src/main/java/org/tron/core/services/filter/BufferedResponseWrapper.java create mode 100644 framework/src/main/java/org/tron/core/services/filter/CachedBodyRequestWrapper.java create mode 100644 framework/src/test/java/org/tron/core/services/filter/BufferedResponseWrapperTest.java create mode 100644 framework/src/test/java/org/tron/core/services/filter/CachedBodyRequestWrapperTest.java create mode 100644 framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java diff --git a/common/src/main/java/org/tron/common/parameter/CommonParameter.java b/common/src/main/java/org/tron/common/parameter/CommonParameter.java index 8f19e607497..f2831b4168f 100644 --- a/common/src/main/java/org/tron/common/parameter/CommonParameter.java +++ b/common/src/main/java/org/tron/common/parameter/CommonParameter.java @@ -494,8 +494,16 @@ public class CommonParameter { public int jsonRpcMaxBlockFilterNum = 50000; @Getter @Setter + public int jsonRpcMaxBatchSize = 100; + @Getter + @Setter + public int jsonRpcMaxResponseSize = 25 * 1024 * 1024; + @Getter + @Setter + public int jsonRpcMaxAddressSize = 1000; + @Getter + @Setter public int jsonRpcMaxLogFilterNum = 20000; - @Getter @Setter public int maxTransactionPendingSize; diff --git a/common/src/main/java/org/tron/core/config/args/NodeConfig.java b/common/src/main/java/org/tron/core/config/args/NodeConfig.java index d280336182d..aac3e930931 100644 --- a/common/src/main/java/org/tron/core/config/args/NodeConfig.java +++ b/common/src/main/java/org/tron/core/config/args/NodeConfig.java @@ -311,6 +311,9 @@ public void setHttpPBFTPort(int v) { private int maxBlockRange = 5000; private int maxSubTopics = 1000; private int maxBlockFilterNum = 50000; + private int maxBatchSize = 100; + private int maxResponseSize = 25 * 1024 * 1024; + private int maxAddressSize = 1000; private int maxLogFilterNum = 20000; private long maxMessageSize = 4194304; } diff --git a/common/src/main/resources/reference.conf b/common/src/main/resources/reference.conf index b2e9898f27b..76225aa0bed 100644 --- a/common/src/main/resources/reference.conf +++ b/common/src/main/resources/reference.conf @@ -380,18 +380,20 @@ node { httpPBFTEnable = false httpPBFTPort = 8565 - # Maximum blocks range for eth_getLogs, >0 otherwise no limit + # The maximum blocks range to retrieve logs for eth_getLogs, default: 5000, <=0 means no limit maxBlockRange = 5000 - - # Maximum topics within a topic criteria, >0 otherwise no limit + # Allowed max address count in filter request, default: 1000, <=0 means no limit + maxAddressSize = 1000 + # The maximum number of allowed topics within a topic criteria, default: 1000, <=0 means no limit maxSubTopics = 1000 - - # Maximum number for blockFilter. >0 otherwise no limit + # Allowed maximum number for blockFilter, default: 50000, <=0 means no limit maxBlockFilterNum = 50000 - - # Maximum number of concurrent eth_newFilter registrations, >0 otherwise no limit + # Allowed batch size, default: 100, <=0 means no limit + maxBatchSize = 100 + # Allowed max response byte size, default: 26214400 (25 MB), <=0 means no limit + maxResponseSize = 26214400 + # Allowed maximum number for newFilter, <=0 means no limit maxLogFilterNum = 20000 - # Maximum JSON-RPC request body size, default 4MB. Independent from rpc.maxMessageSize. maxMessageSize = 4M } diff --git a/framework/build.gradle b/framework/build.gradle index fd59d3cc4e7..0ce33f253cf 100644 --- a/framework/build.gradle +++ b/framework/build.gradle @@ -56,6 +56,7 @@ dependencies { } testImplementation group: 'org.springframework', name: 'spring-test', version: "${springVersion}" + testImplementation group: 'javax.portlet', name: 'portlet-api', version: '3.0.1' implementation group: 'org.zeromq', name: 'jeromq', version: '0.5.3' api project(":chainbase") api project(":protocol") diff --git a/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index a97792f1a19..de8b7dba1ad 100644 --- a/framework/src/main/java/org/tron/core/config/args/Args.java +++ b/framework/src/main/java/org/tron/core/config/args/Args.java @@ -559,6 +559,9 @@ private static void applyNodeConfig(NodeConfig nc) { PARAMETER.jsonRpcMaxBlockRange = jsonrpc.getMaxBlockRange(); PARAMETER.jsonRpcMaxSubTopics = jsonrpc.getMaxSubTopics(); PARAMETER.jsonRpcMaxBlockFilterNum = jsonrpc.getMaxBlockFilterNum(); + PARAMETER.jsonRpcMaxBatchSize = jsonrpc.getMaxBatchSize(); + PARAMETER.jsonRpcMaxResponseSize = jsonrpc.getMaxResponseSize(); + PARAMETER.jsonRpcMaxAddressSize = jsonrpc.getMaxAddressSize(); PARAMETER.jsonRpcMaxLogFilterNum = jsonrpc.getMaxLogFilterNum(); PARAMETER.jsonRpcMaxMessageSize = jsonrpc.getMaxMessageSize(); diff --git a/framework/src/main/java/org/tron/core/services/filter/BufferedResponseWrapper.java b/framework/src/main/java/org/tron/core/services/filter/BufferedResponseWrapper.java new file mode 100644 index 00000000000..7076746b2a0 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/filter/BufferedResponseWrapper.java @@ -0,0 +1,178 @@ +package org.tron.core.services.filter; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import javax.servlet.ServletOutputStream; +import javax.servlet.WriteListener; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletResponseWrapper; +import lombok.Getter; + +/** + * Buffers the response body without writing to the underlying response, + * so the caller can replay it after the handler returns. + * + *

If {@code maxBytes > 0} and the response would exceed that limit, the + * {@link #isOverflow()} flag is set instead of throwing. The caller should check this flag after + * the handler returns and write its own error response when true. + * + *

Header-mutating methods ({@code setStatus}, {@code setContentType}) are buffered here and + * only forwarded to the real response via {@link #commitToResponse()}. + */ +public class BufferedResponseWrapper extends HttpServletResponseWrapper { + + private final HttpServletResponse actual; + private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + private final int maxBytes; + private int status = HttpServletResponse.SC_OK; + private String contentType; + private boolean committed = false; + @Getter + private volatile boolean overflow = false; + + private final ServletOutputStream outputStream = new ServletOutputStream() { + @Override + public void write(int b) { + if (overflow) { + return; + } + if (maxBytes > 0 && buffer.size() >= maxBytes) { + markOverflow(); + return; + } + buffer.write(b); + } + + @Override + public void write(byte[] b, int off, int len) { + if (overflow) { + return; + } + if (maxBytes > 0 && buffer.size() + len > maxBytes) { + markOverflow(); + return; + } + buffer.write(b, off, len); + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setWriteListener(WriteListener writeListener) { + } + }; + + private final PrintWriter writer = + new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8), true); + + /** + * @param response the wrapped response + * @param maxBytes max allowed response bytes; {@code 0} means no limit + */ + public BufferedResponseWrapper(HttpServletResponse response, int maxBytes) { + super(response); + this.actual = response; + this.maxBytes = maxBytes; + } + + private void markOverflow() { + overflow = true; + buffer.reset(); + } + + /** + * Early-detection path: if the framework reports the full content length before writing any + * bytes, we can flag overflow without buffering anything. + */ + @Override + public void setContentLength(int len) { + if (maxBytes > 0 && len > maxBytes) { + markOverflow(); + } + } + + @Override + public void setContentLengthLong(long len) { + if (maxBytes > 0 && len > maxBytes) { + markOverflow(); + } + } + + @Override + public int getStatus() { + return this.status; + } + + @Override + public void setStatus(int sc) { + this.status = sc; + } + + @Override + public void setHeader(String name, String value) { + if ("content-length".equalsIgnoreCase(name)) { + try { + setContentLengthLong(Long.parseLong(value)); + } catch (NumberFormatException ignored) { + // malformed value, skip overflow check + } + } else { + super.setHeader(name, value); + } + } + + @Override + public void addHeader(String name, String value) { + if ("content-length".equalsIgnoreCase(name)) { + try { + setContentLengthLong(Long.parseLong(value)); + } catch (NumberFormatException ignored) { + // malformed value, skip overflow check + } + } else { + super.addHeader(name, value); + } + } + + @Override + public void setContentType(String type) { + this.contentType = type; + } + + @Override + public ServletOutputStream getOutputStream() { + return outputStream; + } + + @Override + public PrintWriter getWriter() { + return writer; + } + + public void commitToResponse() throws IOException { + if (committed) { + throw new IllegalStateException("commitToResponse() already called"); + } + committed = true; + // Flush the PrintWriter's OutputStreamWriter encoder into our ByteArrayOutputStream. + // PrintWriter(autoFlush=true) only auto-flushes on println/printf/format, not print/write, + // so bytes can remain buffered in the encoder until an explicit flush. + writer.flush(); + if (overflow) { + return; + } + if (contentType != null) { + actual.setContentType(contentType); + } + actual.setStatus(status); + actual.setContentLength(buffer.size()); + buffer.writeTo(actual.getOutputStream()); + actual.getOutputStream().flush(); + } +} diff --git a/framework/src/main/java/org/tron/core/services/filter/CachedBodyRequestWrapper.java b/framework/src/main/java/org/tron/core/services/filter/CachedBodyRequestWrapper.java new file mode 100644 index 00000000000..683fe849f71 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/filter/CachedBodyRequestWrapper.java @@ -0,0 +1,97 @@ +package org.tron.core.services.filter; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.IllegalCharsetNameException; +import java.nio.charset.StandardCharsets; +import java.nio.charset.UnsupportedCharsetException; +import javax.servlet.ReadListener; +import javax.servlet.ServletInputStream; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; + +/** + * Wraps a request to replay a pre-read body from a byte array, + * allowing the body to be read more than once. + * + *

Scope: designed for synchronous, raw-body POST endpoints + * (e.g. JSON-RPC). It is NOT compatible with: + *

    + *
  • {@code application/x-www-form-urlencoded} — cached body cannot back + * {@code getParameter*}.
  • + *
  • multipart — {@code getPart()/getParts()} read from the original + * (already-consumed) stream.
  • + *
  • async non-blocking I/O — see {@code setReadListener}.
  • + *
  • request dispatch / forward chains.
  • + *
+ * + *

Multiple calls to {@code getInputStream()} (or {@code getReader()}) + * are allowed and each returns a fresh stream over the same cached body — + * a deliberate extension of the standard servlet contract. + */ +public class CachedBodyRequestWrapper extends HttpServletRequestWrapper { + + private enum BodyAccessor { NONE, STREAM, READER } + + private final byte[] body; + private BodyAccessor accessor = BodyAccessor.NONE; + + public CachedBodyRequestWrapper(HttpServletRequest request, byte[] body) { + super(request); + this.body = body; + } + + @Override + public ServletInputStream getInputStream() { + if (accessor == BodyAccessor.READER) { + throw new IllegalStateException("getReader() has already been called on this request"); + } + accessor = BodyAccessor.STREAM; + final ByteArrayInputStream bais = new ByteArrayInputStream(body); + return new ServletInputStream() { + @Override + public int read() { + return bais.read(); + } + + @Override + public int read(byte[] b, int off, int len) { + return bais.read(b, off, len); + } + + @Override + public boolean isFinished() { + return bais.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener readListener) { + throw new UnsupportedOperationException( + "async I/O is not supported on cached body"); + } + }; + } + + @Override + public BufferedReader getReader() { + if (accessor == BodyAccessor.STREAM) { + throw new IllegalStateException("getInputStream() has already been called on this request"); + } + accessor = BodyAccessor.READER; + String encoding = getCharacterEncoding(); + Charset charset; + try { + charset = encoding != null ? Charset.forName(encoding) : StandardCharsets.UTF_8; + } catch (IllegalCharsetNameException | UnsupportedCharsetException ex) { + charset = StandardCharsets.UTF_8; + } + return new BufferedReader(new InputStreamReader(new ByteArrayInputStream(body), charset)); + } +} diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java index 104a0e9e470..2093930ca98 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/JsonRpcServlet.java @@ -1,10 +1,18 @@ package org.tron.core.services.jsonrpc; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.googlecode.jsonrpc4j.HttpStatusCodeProvider; import com.googlecode.jsonrpc4j.JsonRpcInterceptor; import com.googlecode.jsonrpc4j.JsonRpcServer; import com.googlecode.jsonrpc4j.ProxyUtil; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; import java.util.Collections; import javax.servlet.ServletConfig; import javax.servlet.ServletException; @@ -14,15 +22,30 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.tron.common.parameter.CommonParameter; -import org.tron.core.Wallet; -import org.tron.core.db.Manager; -import org.tron.core.services.NodeInfoService; +import org.tron.core.services.filter.BufferedResponseWrapper; +import org.tron.core.services.filter.CachedBodyRequestWrapper; import org.tron.core.services.http.RateLimiterServlet; @Component @Slf4j(topic = "API") public class JsonRpcServlet extends RateLimiterServlet { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private enum JsonRpcError { + PARSE_ERROR(-32700), + INVALID_REQUEST(-32600), + INTERNAL_ERROR(-32603), + EXCEED_LIMIT(-32005), + RESPONSE_TOO_LARGE(-32003); + + private final int code; + + JsonRpcError(int code) { + this.code = code; + } + } + private JsonRpcServer rpcServer = null; @Autowired @@ -66,6 +89,182 @@ public Integer getJsonRpcCode(int httpStatusCode) { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { - rpcServer.handle(req, resp); + CommonParameter parameter = CommonParameter.getInstance(); + + // Transport IOException from readBody propagates as HTTP 500 (genuine IO failure). + byte[] body = readBody(req.getInputStream()); + JsonNode rootNode; + try { + rootNode = MAPPER.readTree(body); + if (rootNode == null || rootNode.isMissingNode()) { + writeJsonRpcError(resp, JsonRpcError.PARSE_ERROR, "Parse error", null, false); + return; + } + } catch (JsonProcessingException e) { + writeJsonRpcError(resp, JsonRpcError.PARSE_ERROR, "Parse error", null, false); + return; + } + + boolean isBatch = rootNode.isArray(); + if (isBatch && rootNode.isEmpty()) { + writeJsonRpcError(resp, JsonRpcError.INVALID_REQUEST, "Invalid Request", null, false); + return; + } + int batchSize = parameter.getJsonRpcMaxBatchSize(); + if (isBatch && batchSize > 0 && rootNode.size() > batchSize) { + writeJsonRpcError(resp, JsonRpcError.EXCEED_LIMIT, + "Batch size " + rootNode.size() + " exceeds the limit of " + batchSize, null, true); + return; + } + + int maxResponseSize = parameter.getJsonRpcMaxResponseSize(); + if (isBatch) { + handleBatch(resp, rootNode, maxResponseSize); + } else { + handleSingle(req, resp, rootNode, body, maxResponseSize); + } + } + + private void handleSingle(HttpServletRequest req, HttpServletResponse resp, + JsonNode rootNode, byte[] body, int maxResponseSize) throws IOException { + CachedBodyRequestWrapper cachedReq = new CachedBodyRequestWrapper(req, body); + BufferedResponseWrapper bufferedResp = new BufferedResponseWrapper( + resp, maxResponseSize); + + try { + rpcServer.handle(cachedReq, bufferedResp); + } catch (RuntimeException e) { + logger.error("RPC execution failed", e); + writeJsonRpcError(resp, JsonRpcError.INTERNAL_ERROR, "Internal error", + rootNode.get("id"), false); + return; + } + + bufferedResp.commitToResponse(); + if (bufferedResp.isOverflow()) { + writeJsonRpcError(resp, JsonRpcError.RESPONSE_TOO_LARGE, + "Response exceeds the limit of " + maxResponseSize + " bytes", + rootNode.get("id"), false); + } + } + + private void handleBatch(HttpServletResponse resp, JsonNode rootNode, int maxResponseSize) + throws IOException { + + ArrayNode batchResult = MAPPER.createArrayNode(); + int accumulatedSize = 2; // "[]" + boolean overflow = false; + + for (int i = 0; i < rootNode.size(); i++) { + JsonNode subRequest = rootNode.get(i); + + if (overflow) { + // Notifications (no "id") do not get a response even on overflow. + if (subRequest.has("id")) { + batchResult.add(buildErrorNode(JsonRpcError.RESPONSE_TOO_LARGE, + "Response exceeds the limit of " + maxResponseSize + " bytes", + subRequest.get("id"))); + } + continue; + } + + byte[] subBody; + try { + subBody = MAPPER.writeValueAsBytes(subRequest); + } catch (JsonProcessingException e) { + writeJsonRpcError(resp, JsonRpcError.INTERNAL_ERROR, "Internal error", null, true); + return; + } + + ByteArrayOutputStream subOutput = new ByteArrayOutputStream(); + try { + rpcServer.handleRequest(new ByteArrayInputStream(subBody), subOutput); + } catch (RuntimeException e) { + logger.error("RPC execution failed for batch sub-request {}", i, e); + writeJsonRpcError(resp, JsonRpcError.INTERNAL_ERROR, "Internal error", null, true); + return; + } + + byte[] responseBytes = subOutput.toByteArray(); + if (responseBytes.length == 0) { + continue; // notification — no response + } + + // comma(,) separator between array elements + int addition = responseBytes.length + (!batchResult.isEmpty() ? 1 : 0); + if (maxResponseSize > 0 && accumulatedSize + addition > maxResponseSize) { + overflow = true; + batchResult.add(buildErrorNode(JsonRpcError.RESPONSE_TOO_LARGE, + "Response exceeds the limit of " + maxResponseSize + " bytes", + subRequest.get("id"))); + continue; + } + accumulatedSize += addition; + + JsonNode responseNode; + try { + responseNode = MAPPER.readTree(responseBytes); + } catch (IOException e) { + writeJsonRpcError(resp, JsonRpcError.INTERNAL_ERROR, "Internal error", null, true); + return; + } + batchResult.add(responseNode); + } + + // JSON-RPC 2.0 §6: MUST NOT return an empty Array when there are no response objects. + if (batchResult.isEmpty()) { + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentLength(0); + return; + } + + byte[] finalBytes = MAPPER.writeValueAsBytes(batchResult); + resp.setContentType("application/json-rpc; charset=utf-8"); + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentLength(finalBytes.length); + resp.getOutputStream().write(finalBytes); + resp.getOutputStream().flush(); + } + + private byte[] readBody(InputStream in) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] tmp = new byte[4096]; + int n; + while ((n = in.read(tmp)) != -1) { + buffer.write(tmp, 0, n); + } + return buffer.toByteArray(); + } + + private ObjectNode buildErrorNode(JsonRpcError error, String message, JsonNode id) { + ObjectNode errorObj = MAPPER.createObjectNode(); + errorObj.put("jsonrpc", "2.0"); + ObjectNode errNode = errorObj.putObject("error"); + errNode.put("code", error.code); + errNode.put("message", message); + if (id != null && !id.isNull() && !id.isMissingNode()) { + errorObj.set("id", id); + } else { + errorObj.putNull("id"); + } + return errorObj; + } + + private void writeJsonRpcError(HttpServletResponse resp, JsonRpcError error, String message, + JsonNode id, boolean isBatch) throws IOException { + ObjectNode errorObj = buildErrorNode(error, message, id); + byte[] bytes; + if (isBatch) { + ArrayNode arr = MAPPER.createArrayNode(); + arr.add(errorObj); + bytes = MAPPER.writeValueAsBytes(arr); + } else { + bytes = MAPPER.writeValueAsBytes(errorObj); + } + resp.setContentType("application/json-rpc; charset=utf-8"); + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentLength(bytes.length); + resp.getOutputStream().write(bytes); + resp.getOutputStream().flush(); } -} \ No newline at end of file +} diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/filters/LogFilter.java b/framework/src/main/java/org/tron/core/services/jsonrpc/filters/LogFilter.java index 42bc123d4bc..d2bd58f6c56 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/filters/LogFilter.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/filters/LogFilter.java @@ -50,6 +50,10 @@ public LogFilter(FilterRequest fr) throws JsonRpcInvalidParamsException { withContractAddress(addressToByteArray((String) fr.getAddress())); } else if (fr.getAddress() instanceof ArrayList) { + int maxAddressSize = Args.getInstance().getJsonRpcMaxAddressSize(); + if (maxAddressSize > 0 && ((ArrayList) fr.getAddress()).size() > maxAddressSize) { + throw new JsonRpcInvalidParamsException("exceed max addresses: " + maxAddressSize); + } List addr = new ArrayList<>(); int i = 0; for (Object s : (ArrayList) fr.getAddress()) { diff --git a/framework/src/main/resources/config.conf b/framework/src/main/resources/config.conf index 4c26cb90fba..b180ecd6d10 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -388,17 +388,22 @@ node { # httpPBFTEnable = false # httpPBFTPort = 8565 - # The maximum blocks range to retrieve logs for eth_getLogs, default value is 5000, - # should be > 0, otherwise means no limit. + # The maximum blocks range to retrieve logs for eth_getLogs, default: 5000, <=0 means no limit maxBlockRange = 5000 - - # The maximum number of allowed topics within a topic criteria, default value is 1000, - # should be > 0, otherwise means no limit. + # Allowed max address count in filter request, default: 1000, <=0 means no limit + maxAddressSize = 1000 + # The maximum number of allowed topics within a topic criteria, default: 1000, <=0 means no limit maxSubTopics = 1000 - # Allowed maximum number for blockFilter, >0 otherwise no limit + # Allowed maximum number for blockFilter, default: 50000, <=0 means no limit maxBlockFilterNum = 50000 - # Allowed maximum number for newFilter, >0 otherwise no limit + # Allowed batch size, default: 100, <=0 means no limit + maxBatchSize = 100 + # Allowed max response byte size, default: 26214400 (25 MB), <=0 means no limit + maxResponseSize = 26214400 + # Allowed maximum number for newFilter, <=0 means no limit maxLogFilterNum = 20000 + # Maximum JSON-RPC request body size, default 4MB. Independent from rpc.maxMessageSize. + maxMessageSize = 4M } # Disabled api list, it will work for http, rpc and pbft, both FullNode and SolidityNode, diff --git a/framework/src/test/java/org/tron/core/jsonrpc/JsonRpcTest.java b/framework/src/test/java/org/tron/core/jsonrpc/JsonRpcTest.java index bd357101da3..5f577194dff 100644 --- a/framework/src/test/java/org/tron/core/jsonrpc/JsonRpcTest.java +++ b/framework/src/test/java/org/tron/core/jsonrpc/JsonRpcTest.java @@ -8,12 +8,14 @@ import java.util.ArrayList; import java.util.BitSet; +import java.util.Collections; import java.util.List; import org.bouncycastle.util.encoders.Hex; import org.junit.Assert; import org.junit.Test; import org.tron.common.bloom.Bloom; import org.tron.common.crypto.Hash; +import org.tron.common.parameter.CommonParameter; import org.tron.common.runtime.vm.DataWord; import org.tron.common.utils.ByteArray; import org.tron.common.utils.ByteUtil; @@ -242,6 +244,58 @@ public void testLogFilter() { } } + @Test + public void testLogFilterAddressSizeLimit() { + // Two valid 20-byte addresses (40 hex chars with 0x prefix) + String addr1 = "0xaa6612f03443517ced2bdcf27958c22353ceeab9"; + String addr2 = "0xbb7723a04554628ced3cdf38069b433464ffbc0a"; + String addr3 = "0xcc8834b15665739def4de049f17a544575aabcd1"; + + int savedLimit = CommonParameter.getInstance().jsonRpcMaxAddressSize; + try { + CommonParameter.getInstance().jsonRpcMaxAddressSize = 2; + + // Exactly at limit — must not throw + ArrayList atLimit = new ArrayList<>(); + atLimit.add(addr1); + atLimit.add(addr2); + FilterRequest frAtLimit = new FilterRequest(); + frAtLimit.setAddress(atLimit); + try { + new LogFilter(frAtLimit); + } catch (JsonRpcInvalidParamsException e) { + Assert.fail("address list at limit should not throw: " + e.getMessage()); + } + + // One over limit — must throw with expected message + ArrayList overLimit = new ArrayList<>(); + overLimit.add(addr1); + overLimit.add(addr2); + overLimit.add(addr3); + FilterRequest frOverLimit = new FilterRequest(); + frOverLimit.setAddress(overLimit); + try { + new LogFilter(frOverLimit); + Assert.fail("address list over limit should have thrown JsonRpcInvalidParamsException"); + } catch (JsonRpcInvalidParamsException e) { + Assert.assertTrue(e.getMessage().contains("exceed max addresses:")); + } + + // Limit = 0 means disabled — large list must pass + CommonParameter.getInstance().jsonRpcMaxAddressSize = 0; + ArrayList largeList = new ArrayList<>(Collections.nCopies(500, addr1)); + FilterRequest frDisabled = new FilterRequest(); + frDisabled.setAddress(largeList); + try { + new LogFilter(frDisabled); + } catch (JsonRpcInvalidParamsException e) { + Assert.fail("limit=0 should disable the check: " + e.getMessage()); + } + } finally { + CommonParameter.getInstance().jsonRpcMaxAddressSize = savedLimit; + } + } + private int[] getBloomIndex(String s) { Bloom bloom = Bloom.create(Hash.sha3(ByteArray.fromHexString(s))); BitSet bs = BitSet.valueOf(bloom.getData()); diff --git a/framework/src/test/java/org/tron/core/services/filter/BufferedResponseWrapperTest.java b/framework/src/test/java/org/tron/core/services/filter/BufferedResponseWrapperTest.java new file mode 100644 index 00000000000..d7828fa5cd0 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/filter/BufferedResponseWrapperTest.java @@ -0,0 +1,287 @@ +package org.tron.core.services.filter; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletResponse; + +public class BufferedResponseWrapperTest { + + private MockHttpServletResponse mockResp; + + @Before + public void setUp() { + mockResp = new MockHttpServletResponse(); + } + + // --- isOverflow: false cases --- + + @Test + public void noLimit_neverOverflows() throws IOException { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 0); + w.getOutputStream().write(new byte[1024 * 1024]); + assertFalse(w.isOverflow()); + } + + @Test + public void withinLimit_notOverflow() throws IOException { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 10); + w.getOutputStream().write(new byte[10]); + assertFalse(w.isOverflow()); + } + + @Test + public void exactlyAtLimit_notOverflow() throws IOException { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 5); + w.getOutputStream().write(new byte[]{1, 2, 3, 4, 5}); + assertFalse(w.isOverflow()); + } + + // --- isOverflow: true via write --- + + @Test + public void oneBytePastLimit_overflow() throws IOException { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 5); + w.getOutputStream().write(new byte[]{1, 2, 3, 4, 5, 6}); + assertTrue(w.isOverflow()); + } + + @Test + public void singleByteWrite_triggerOverflow() throws IOException { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 3); + w.getOutputStream().write(1); + w.getOutputStream().write(2); + w.getOutputStream().write(3); + assertFalse(w.isOverflow()); + w.getOutputStream().write(4); + assertTrue(w.isOverflow()); + } + + @Test + public void overflow_bufferIsReleasedOnOverflow() throws IOException { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 4); + w.getOutputStream().write(new byte[]{1, 2, 3, 4, 5}); + assertTrue(w.isOverflow()); + // After overflow, further writes are silently discarded — no exception + w.getOutputStream().write(new byte[100]); + assertTrue(w.isOverflow()); + } + + // --- isOverflow: true via setContentLength --- + + @Test + public void setContentLength_exceedsLimit_overflow() { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 100); + w.setContentLength(101); + assertTrue(w.isOverflow()); + } + + @Test + public void setContentLength_exactlyAtLimit_notOverflow() { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 100); + w.setContentLength(100); + assertFalse(w.isOverflow()); + } + + @Test + public void setContentLengthLong_exceedsLimit_overflow() { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 100); + w.setContentLengthLong(101L); + assertTrue(w.isOverflow()); + } + + @Test + public void setContentLength_noLimit_neverOverflows() { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 0); + w.setContentLength(Integer.MAX_VALUE); + assertFalse(w.isOverflow()); + } + + // --- setContentLength early detection: writes after early overflow are discarded --- + + @Test + public void earlyOverflow_subsequentWritesDiscarded() throws IOException { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 10); + w.setContentLength(20); + assertTrue(w.isOverflow()); + w.getOutputStream().write(new byte[5]); + // Nothing committed to actual response + assertFalse(mockResp.isCommitted()); + } + + // --- commitToResponse --- + + @Test + public void commitToResponse_writesBodyAndHeaders() throws IOException { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 0); + byte[] data = "hello".getBytes(StandardCharsets.UTF_8); + w.setStatus(200); + w.setContentType("application/json"); + w.getOutputStream().write(data); + w.commitToResponse(); + + assertEquals(200, mockResp.getStatus()); + assertEquals("application/json", mockResp.getContentType()); + assertArrayEquals(data, mockResp.getContentAsByteArray()); + } + + @Test + public void commitToResponse_setsCorrectContentLength() throws IOException { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 0); + byte[] data = new byte[]{10, 20, 30}; + w.getOutputStream().write(data); + w.commitToResponse(); + + assertEquals(3, mockResp.getContentLength()); + } + + @Test + public void commitToResponse_emptyBuffer_writesZeroBytes() throws IOException { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 100); + w.setStatus(200); + w.commitToResponse(); + + assertEquals(0, mockResp.getContentLength()); + assertEquals(0, mockResp.getContentAsByteArray().length); + } + + // --- header buffering: nothing reaches actual response until commit --- + + @Test + public void statusNotForwardedBeforeCommit() throws IOException { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 0); + w.setStatus(201); + // MockHttpServletResponse defaults to 200 + assertEquals(200, mockResp.getStatus()); + w.commitToResponse(); + assertEquals(201, mockResp.getStatus()); + } + + // --- getStatus() --- + + @Test + public void getStatus_returnsBufferedValue() { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 0); + w.setStatus(404); + assertEquals(404, w.getStatus()); + // actual response must still be untouched + assertEquals(200, mockResp.getStatus()); + } + + @Test + public void getStatus_defaultIs200() { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 0); + assertEquals(200, w.getStatus()); + } + + // --- setHeader / addHeader for Content-Length --- + + @Test + public void setHeader_contentLength_exceedsLimit_overflow() { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 100); + w.setHeader("Content-Length", "101"); + assertTrue(w.isOverflow()); + // Content-Length must NOT have been forwarded to the actual response + assertNull(mockResp.getHeader("Content-Length")); + } + + @Test + public void setHeader_contentLength_withinLimit_noOverflow() { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 100); + w.setHeader("Content-Length", "100"); + assertFalse(w.isOverflow()); + } + + @Test + public void setHeader_contentLength_caseInsensitive_overflow() { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 50); + w.setHeader("content-length", "51"); + assertTrue(w.isOverflow()); + } + + @Test + public void setHeader_contentLength_malformed_ignored() { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 100); + w.setHeader("Content-Length", "not-a-number"); + assertFalse(w.isOverflow()); + } + + @Test + public void setHeader_nonContentLength_passesThroughToActual() { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 0); + w.setHeader("X-Custom-Header", "hello"); + assertEquals("hello", mockResp.getHeader("X-Custom-Header")); + } + + @Test + public void addHeader_contentLength_exceedsLimit_overflow() { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 100); + w.addHeader("Content-Length", "200"); + assertTrue(w.isOverflow()); + assertNull(mockResp.getHeader("Content-Length")); + } + + @Test + public void addHeader_contentLength_malformed_ignored() { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 100); + w.addHeader("Content-Length", "bad"); + assertFalse(w.isOverflow()); + } + + @Test + public void addHeader_nonContentLength_passesThroughToActual() { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 0); + w.addHeader("X-Trace-Id", "abc123"); + assertEquals("abc123", mockResp.getHeader("X-Trace-Id")); + } + + // --- commitToResponse idempotency --- + + @Test(expected = IllegalStateException.class) + public void commitToResponse_secondCall_throwsIllegalState() throws IOException { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 0); + w.commitToResponse(); + w.commitToResponse(); + } + + // --- getWriter path --- + + @Test + public void writeViaWriter_commitToResponse_flushesBody() throws IOException { + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 0); + w.getWriter().print("hello"); + w.getWriter().flush(); + w.commitToResponse(); + assertEquals("hello", mockResp.getContentAsString()); + } + + @Test + public void writeViaWriter_noExplicitFlush_commitToResponse_flushesBody() throws IOException { + // Regression: PrintWriter(autoFlush=true) does NOT flush on plain print(); bytes can sit + // in the OutputStreamWriter encoder until commitToResponse() flushes the writer internally. + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 0); + w.getWriter().print("hello"); + w.commitToResponse(); + assertEquals("hello", mockResp.getContentAsString()); + assertEquals(5, mockResp.getContentLength()); + } + + @Test + public void writeViaWriter_noExplicitFlush_flushTripsOverflow() throws IOException { + // Regression: bytes buffered in the encoder may push the total past maxBytes when + // commitToResponse() flushes — overflow must be detected and nothing written to actual. + BufferedResponseWrapper w = new BufferedResponseWrapper(mockResp, 3); + w.getWriter().print("hello"); // 5 bytes, not yet in ByteArrayOutputStream + assertFalse("overflow must not trigger before flush", w.isOverflow()); + w.commitToResponse(); + assertTrue("flush inside commitToResponse must trip overflow", w.isOverflow()); + assertEquals(0, mockResp.getContentAsByteArray().length); + } +} diff --git a/framework/src/test/java/org/tron/core/services/filter/CachedBodyRequestWrapperTest.java b/framework/src/test/java/org/tron/core/services/filter/CachedBodyRequestWrapperTest.java new file mode 100644 index 00000000000..813b1a61bea --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/filter/CachedBodyRequestWrapperTest.java @@ -0,0 +1,109 @@ +package org.tron.core.services.filter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +public class CachedBodyRequestWrapperTest { + + private static final byte[] BODY = "hello world".getBytes(StandardCharsets.UTF_8); + + private static byte[] readFully(javax.servlet.ServletInputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buf = new byte[128]; + int n; + while ((n = in.read(buf)) != -1) { + out.write(buf, 0, n); + } + return out.toByteArray(); + } + + // --- getInputStream --- + + @Test + public void getInputStream_returnsBodyContent() throws IOException { + CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); + byte[] read = readFully(w.getInputStream()); + assertEquals(new String(BODY, StandardCharsets.UTF_8), + new String(read, StandardCharsets.UTF_8)); + } + + @Test + public void getInputStream_calledTwice_bothSucceed() throws IOException { + CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); + w.getInputStream(); + // second call of the same accessor is allowed by the servlet spec + w.getInputStream(); + } + + // --- getReader --- + + @Test + public void getReader_returnsBodyContent() throws IOException { + CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); + String line = w.getReader().readLine(); + assertEquals("hello world", line); + } + + @Test + public void getReader_calledTwice_bothSucceed() throws IOException { + CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); + w.getReader(); + w.getReader(); + } + + // --- mutual exclusion --- + + @Test(expected = IllegalStateException.class) + public void getReader_afterGetInputStream_throws() throws IOException { + CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); + w.getInputStream(); + w.getReader(); + } + + @Test(expected = IllegalStateException.class) + public void getInputStream_afterGetReader_throws() throws IOException { + CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); + w.getReader(); + w.getInputStream(); + } + + // --- stream contract --- + + @Test + public void getInputStream_isFinished_afterFullRead() throws IOException { + CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); + javax.servlet.ServletInputStream in = w.getInputStream(); + while (in.read() != -1) { + // drain + } + assertTrue(in.isFinished()); + } + + @Test + public void getInputStream_isReady_returnsTrue() { + CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), BODY); + assertTrue(w.getInputStream().isReady()); + } + + @Test + public void getInputStream_emptyBody_isFinishedImmediately() { + CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(new MockHttpServletRequest(), + new byte[0]); + assertTrue(w.getInputStream().isFinished()); + } + + @Test + public void getReader_usesRequestCharacterEncoding() throws IOException { + MockHttpServletRequest req = new MockHttpServletRequest(); + req.setCharacterEncoding("UTF-8"); + byte[] utf8Body = "tron".getBytes(StandardCharsets.UTF_8); + CachedBodyRequestWrapper w = new CachedBodyRequestWrapper(req, utf8Body); + assertEquals("tron", w.getReader().readLine()); + } +} diff --git a/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java new file mode 100644 index 00000000000..fa45ca48876 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/jsonrpc/JsonRpcServletTest.java @@ -0,0 +1,264 @@ +package org.tron.core.services.jsonrpc; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.googlecode.jsonrpc4j.JsonRpcServer; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.tron.common.parameter.CommonParameter; + +public class JsonRpcServletTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private TestableServlet servlet; + private JsonRpcServer mockRpcServer; + private int savedMaxBatchSize; + private int savedMaxResponseSize; + + @Before + public void setUp() throws Exception { + servlet = new TestableServlet(); + mockRpcServer = mock(JsonRpcServer.class); + Field f = JsonRpcServlet.class.getDeclaredField("rpcServer"); + f.setAccessible(true); + f.set(servlet, mockRpcServer); + savedMaxBatchSize = CommonParameter.getInstance().jsonRpcMaxBatchSize; + savedMaxResponseSize = CommonParameter.getInstance().jsonRpcMaxResponseSize; + } + + @After + public void tearDown() { + CommonParameter.getInstance().jsonRpcMaxBatchSize = savedMaxBatchSize; + CommonParameter.getInstance().jsonRpcMaxResponseSize = savedMaxResponseSize; + } + + // --- parse error paths --- + + @Test + public void invalidJson_returnsParseError() throws Exception { + MockHttpServletResponse resp = doPost("not {{ valid json"); + assertEquals(200, resp.getStatus()); + JsonNode body = MAPPER.readTree(resp.getContentAsString()); + assertFalse(body.isArray()); + assertEquals(-32700, body.get("error").get("code").asInt()); + assertEquals("2.0", body.get("jsonrpc").asText()); + assertTrue(body.get("id").isNull()); + } + + @Test + public void emptyBody_returnsParseError() throws Exception { + MockHttpServletResponse resp = doPost(""); + assertEquals(200, resp.getStatus()); + JsonNode body = MAPPER.readTree(resp.getContentAsString()); + assertEquals(-32700, body.get("error").get("code").asInt()); + } + + // --- batch size limit --- + + @Test + public void batchExceedsLimit_returnsExceedLimitAsArray() throws Exception { + CommonParameter.getInstance().jsonRpcMaxBatchSize = 2; + MockHttpServletResponse resp = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]"); + assertEquals(200, resp.getStatus()); + JsonNode body = MAPPER.readTree(resp.getContentAsString()); + assertTrue("batch error response must be a JSON array", body.isArray()); + assertEquals(1, body.size()); + assertEquals(-32005, body.get(0).get("error").get("code").asInt()); + } + + @Test + public void batchWithinLimit_proceedsToRpcServer() throws Exception { + CommonParameter.getInstance().jsonRpcMaxBatchSize = 5; + byte[] singleResp = "{\"jsonrpc\":\"2.0\",\"result\":\"ok\",\"id\":1}" + .getBytes(StandardCharsets.UTF_8); + doAnswer(inv -> { + OutputStream out = inv.getArgument(1); + out.write(singleResp); + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + + MockHttpServletResponse resp = doPost("[{\"id\":1},{\"id\":2}]"); + assertEquals(200, resp.getStatus()); + JsonNode body = MAPPER.readTree(resp.getContentAsByteArray()); + assertTrue("batch response must be a JSON array", body.isArray()); + assertEquals("each sub-request must produce a response", 2, body.size()); + assertEquals("ok", body.get(0).get("result").asText()); + } + + @Test + public void emptyBatch_returnsInvalidRequest() throws Exception { + MockHttpServletResponse resp = doPost("[]"); + assertEquals(200, resp.getStatus()); + JsonNode body = MAPPER.readTree(resp.getContentAsString()); + assertFalse("empty-batch error response must be a single object, not an array", body.isArray()); + assertEquals(-32600, body.get("error").get("code").asInt()); + assertEquals("2.0", body.get("jsonrpc").asText()); + assertTrue(body.get("id").isNull()); + } + + @Test + public void batchLimitDisabled_largeBatchAllowed() throws Exception { + CommonParameter.getInstance().jsonRpcMaxBatchSize = 0; + // write nothing — simulates notifications (no response expected) + doAnswer(inv -> 0).when(mockRpcServer) + .handleRequest(any(InputStream.class), any(OutputStream.class)); + + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < 500; i++) { + if (i > 0) { + sb.append(','); + } + sb.append("{}"); + } + sb.append("]"); + MockHttpServletResponse resp = doPost(sb.toString()); + assertEquals(200, resp.getStatus()); + assertEquals("all-notification batch must return empty body per JSON-RPC 2.0 §6", + 0, resp.getContentLength()); + assertEquals("", resp.getContentAsString()); + } + + // --- rpcServer.handle exceptions --- + + @Test + public void rpcServerThrowsRuntimeException_returnsInternalError() throws Exception { + doThrow(new RuntimeException("server exploded")).when(mockRpcServer) + .handle(any(HttpServletRequest.class), any(HttpServletResponse.class)); + MockHttpServletResponse resp = doPost("{\"method\":\"eth_blockNumber\",\"id\":42}"); + assertEquals(200, resp.getStatus()); + JsonNode body = MAPPER.readTree(resp.getContentAsString()); + assertFalse(body.isArray()); + assertEquals(-32603, body.get("error").get("code").asInt()); + } + + @Test + public void batchRpcServerThrows_internalErrorIsArray() throws Exception { + doThrow(new RuntimeException("boom")).when(mockRpcServer) + .handleRequest(any(InputStream.class), any(OutputStream.class)); + MockHttpServletResponse resp = doPost("[{\"method\":\"eth_blockNumber\"}]"); + assertEquals(200, resp.getStatus()); + JsonNode body = MAPPER.readTree(resp.getContentAsString()); + assertTrue("batch internal error must be an array", body.isArray()); + assertEquals(-32603, body.get(0).get("error").get("code").asInt()); + } + + // --- response size limit --- + + @Test + public void responseTooLarge_returnsSingleErrorObject() throws Exception { + int limit = 50; + CommonParameter.getInstance().jsonRpcMaxResponseSize = limit; + doAnswer(inv -> { + HttpServletResponse r = inv.getArgument(1); + r.getOutputStream().write(new byte[limit + 1]); + return null; + }).when(mockRpcServer).handle(any(HttpServletRequest.class), any(HttpServletResponse.class)); + + MockHttpServletResponse resp = doPost("{\"method\":\"eth_getLogs\",\"id\":1}"); + assertEquals(200, resp.getStatus()); + JsonNode body = MAPPER.readTree(resp.getContentAsString()); + assertFalse(body.isArray()); + assertEquals(-32003, body.get("error").get("code").asInt()); + } + + @Test + public void batchResponseTooLarge_returnsErrorArray() throws Exception { + int limit = 50; + CommonParameter.getInstance().jsonRpcMaxResponseSize = limit; + doAnswer(inv -> { + OutputStream out = inv.getArgument(1); + out.write(new byte[limit + 1]); + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + + MockHttpServletResponse resp = doPost("[{\"method\":\"eth_getLogs\"}]"); + assertEquals(200, resp.getStatus()); + JsonNode body = MAPPER.readTree(resp.getContentAsString()); + assertTrue("batch response-too-large must be an array", body.isArray()); + assertEquals(-32003, body.get(0).get("error").get("code").asInt()); + } + + @Test + public void batchShortCircuitsOnOverflow() throws Exception { + int limit = 50; + CommonParameter.getInstance().jsonRpcMaxResponseSize = limit; + int[] callCount = {0}; + doAnswer(inv -> { + OutputStream out = inv.getArgument(1); + callCount[0]++; + if (callCount[0] == 1) { + out.write("{\"result\":\"ok\"}".getBytes(StandardCharsets.UTF_8)); + } else { + out.write(new byte[limit]); // triggers overflow when added to accumulated size + } + return 0; + }).when(mockRpcServer).handleRequest(any(InputStream.class), any(OutputStream.class)); + + MockHttpServletResponse resp = doPost("[{\"id\":1},{\"id\":2},{\"id\":3}]"); + assertEquals(200, resp.getStatus()); + JsonNode body = MAPPER.readTree(resp.getContentAsString()); + assertTrue("overflow response must be an array", body.isArray()); + // Geth-compatible: previous successes are preserved; overflow item and remaining + // unexecuted items each get a -32003 error with their original id. + assertEquals(3, body.size()); + assertEquals("ok", body.get(0).get("result").asText()); + assertEquals(-32003, body.get(1).get("error").get("code").asInt()); + assertEquals(2, body.get(1).get("id").asInt()); + assertEquals(-32003, body.get(2).get("error").get("code").asInt()); + assertEquals(3, body.get(2).get("id").asInt()); + assertEquals("third sub-request must not be executed after overflow", 2, callCount[0]); + } + + // --- normal path --- + + @Test + public void normalRequest_commitsRpcServerResponse() throws Exception { + byte[] rpcResp = "{\"result\":\"0x1\"}".getBytes(StandardCharsets.UTF_8); + doAnswer(inv -> { + HttpServletResponse r = inv.getArgument(1); + r.getOutputStream().write(rpcResp); + return null; + }).when(mockRpcServer).handle(any(HttpServletRequest.class), any(HttpServletResponse.class)); + + MockHttpServletResponse resp = doPost("{\"method\":\"eth_blockNumber\",\"id\":1}"); + assertEquals(200, resp.getStatus()); + assertArrayEquals(rpcResp, resp.getContentAsByteArray()); + } + + // --- helpers --- + + private MockHttpServletResponse doPost(String body) throws Exception { + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/jsonrpc"); + req.setContent(body.getBytes(StandardCharsets.UTF_8)); + MockHttpServletResponse resp = new MockHttpServletResponse(); + servlet.callDoPost(req, resp); + return resp; + } + + private static class TestableServlet extends JsonRpcServlet { + + void callDoPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + doPost(req, resp); + } + } +}