Repository navigation
[Feature] Deduplicate HTTP servlet stacks with cursor filters and a declarative endpoint registry #6922
Description
Activity
- added a parent issue
on Aug 20, 2026 This proposal looks solid to me. It addresses the duplication in the current HTTP API layer while making endpoint exposure and access constraints much clearer.
I also noticed that there are many relatively small servlet implementation classes under
org.tron.core.services.http. Have you considered organizing and consolidating them by functional domain, such as account and resources, blocks and transactions, contracts, governance, assets and markets, shielded APIs, and node-related APIs?This could potentially make the package easier to navigate and maintain. I would be interested to hear your thoughts on whether such a follow-up refactoring would be worthwhile after this work.
SeriousCoding789 commented
on Aug 21, 2026 ContributorAuthorMore actionsThanks @yanghang8612 — agreed, and worth doing. On develop that package holds 133 servlet classes in a single flat directory, so grouping by domain would help a lot.
Confirming the scope boundary: this proposal removes the duplicated wrapper servlets in the Solidity and PBFT stacks and replaces the hand-written registration lists with a single declarative table — the base servlets in services.http are left untouched. Folding the reorganization into the same change would make it considerably larger and harder to review, so I'd rather keep the two separate.
Once this work is done, I'll give the reorganization serious consideration as a follow-up, using your domains as the starting taxonomy. Step 2's registry (endpoint → servlet → access nature → surfaces) should also make most of the grouping derivable from the table rather than hand-assigned.
@Sunny6889 Thanks for the clarification. Keeping the two changes separate makes sense and should make this proposal much easier to review. I’m glad the suggested domains can provide a useful starting point for a follow-up.
Thanks @Sunny6889 — I agree with the overall direction of both Step 1 and Step 2.
For the registration-dedup half, I previously explored annotation-based auto-registration in this prototype. It is based on an older codebase and covers registration dedup only — not Step 2's access modeling or safety invariants — so I am linking it as prior art rather than proposing the commit as-is.
There may be a way to combine the two approaches.
HttpApiDefand a sufficiently complete servlet annotation encode the same endpoint metadata — suffix, access, and surfaces — with the servlet type implicit in the annotated class. If both are hand-written, we're back to two sources of truth. Could we instead make the annotation the single declaration source, and derive a read-only registry and audit matrix from it?- After the wrappers are removed, each base servlet declares its canonical suffix, access (
READ / BUILD / WRITE), and applicable surfaces through one unified@HttpApiannotation; special aliases are declared explicitly. - A shared registrar registers routes from this metadata and, before any Jetty bind and regardless of whether the corresponding surface is enabled, validates that each
(surface, path)is unique, paths are non-blank, every annotated class is a Spring-managed servlet bean, every concrete servlet under the registry-managed HTTP API package is either explicitly annotated or explicitly opted out, and every endpoint withaccess != READdeclares the FullNode surface and no others. - The
API × surface × accessaudit matrix is generated from the same metadata. During migration, parity should still be checked against an independent fixture of the old hand-written routes so that the generated output does not validate itself. Afterwards, a CI-checked generated snapshot would let reviewers see surface changes at a glance.
This way, annotations provide locality of declaration, while the derived registry provides centralized auditability and boot-time safety, without introducing a second hand-maintained table. One implementation boundary is worth preserving: only directly declared annotations should be accepted, and
@HttpApishould never be@Inherited, so Spring's superclass annotation lookup cannot accidentally expose an endpoint.Would you consider making the role currently served by the hand-written
HttpApiDefenum a derived, read-only registry/view instead?- After the wrappers are removed, each base servlet declares its canonical suffix, access (
SeriousCoding789 commented
on Aug 24, 2026 ContributorAuthorMore actionsThanks @Sunny6889 — I agree with the overall direction of both Step 1 and Step 2.
For the registration-dedup half, I previously explored annotation-based auto-registration in this prototype. It is based on an older codebase and covers registration dedup only — not Step 2's access modeling or safety invariants — so I am linking it as prior art rather than proposing the commit as-is.
There may be a way to combine the two approaches.
HttpApiDefand a sufficiently complete servlet annotation encode the same endpoint metadata — suffix, access, and surfaces — with the servlet type implicit in the annotated class. If both are hand-written, we're back to two sources of truth. Could we instead make the annotation the single declaration source, and derive a read-only registry and audit matrix from it?- After the wrappers are removed, each base servlet declares its canonical suffix, access (
READ / BUILD / WRITE), and applicable surfaces through one unified@HttpApiannotation; special aliases are declared explicitly. - A shared registrar registers routes from this metadata and, before any Jetty bind and regardless of whether the corresponding surface is enabled, validates that each
(surface, path)is unique, paths are non-blank, every annotated class is a Spring-managed servlet bean, every concrete servlet under the registry-managed HTTP API package is either explicitly annotated or explicitly opted out, and every endpoint withaccess != READdeclares the FullNode surface and no others. - The
API × surface × accessaudit matrix is generated from the same metadata. During migration, parity should still be checked against an independent fixture of the old hand-written routes so that the generated output does not validate itself. Afterwards, a CI-checked generated snapshot would let reviewers see surface changes at a glance.
This way, annotations provide locality of declaration, while the derived registry provides centralized auditability and boot-time safety, without introducing a second hand-maintained table. One implementation boundary is worth preserving: only directly declared annotations should be accepted, and
@HttpApishould never be@Inherited, so Spring's superclass annotation lookup cannot accidentally expose an endpoint.Would you consider making the role currently served by the hand-written
HttpApiDefenum a derived, read-only registry/view instead?Thanks @halibobo1205 — I'd like to take exactly this approach. Plan: make HttpApiDef's role a read-only registry derived from a single @httpapi(value, access, surfaces) declaration on each servlet, with the audit matrix generated from the same source. HttpApiRegistry would build and validate the whole table at class-load, before any Jetty bind, so an invalid table fails the boot rather than mounting.
Both of your implementation notes would be enforced (and pinned by tests): @httpapi never @inherited and read only via getDeclaredAnnotation, and every concrete servlet under the package must declare either @httpapi or an explicit @HttpApiExcluded — so a servlet can't be added and silently left unmounted. The API × surface × access matrix would be generated and checked against a committed snapshot, and — per your point about not letting the output validate itself — I'd keep a separate fixture of the pre-refactor hand-written routes as the independent parity baseline.
That baseline looks worth having: a local prototype of it already surfaced that the hand-written table drops five shielded read endpoints still live on the PBFT surface (getmerkletreevoucherinfo, scanandmarknotebyivk, scannotebyivk, scannotebyovk, isspend), which the derived-plus-baseline setup would catch and preserve. I'll fold this into the PR. Thanks for pushing on it.
- After the wrappers are removed, each base servlet declares its canonical suffix, access (
The annotation-derived registry direction looks good to me and resolves the two-sources-of-truth concern. Before implementation, I think two points need to be made explicit:
-
The latest reply says the independent old-route fixture should catch and preserve the five PBFT shielded endpoints. That conflicts with the issue body (including Compatibility item 3), which describes them as a missed 2020 deletion and says they will be removed. Current
upstream/developconfirms that these routes are still active only inHttpApiOnPBFTService, while the corresponding FullNode registrations are commented out. The parity fixture therefore needs to distinguish exact parity from reviewed, intentional deltas—for example, an explicit expected-diff/allowlist pinned by tests. Please choose remove or preserve and make the proposal, fixture, and generated matrix agree. -
“Build and validate the whole table at class-load” is too early for the check that every annotated servlet is a Spring-managed bean. Metadata-only invariants (direct annotation, unique
(surface, path), non-blank paths, access/surface rules) can be checked statically, but bean resolution must happen after theApplicationContextis ready and beforeHttpService.start()binds Jetty. I suggest specifying two validation phases and adding a startup-order test proving that a context-validation failure leaves no HTTP port bound.
With those clarified, I support making the annotation the declaration source and the registry a derived, read-only audit view.
-
- addedtopic:apirpc/http related issuerpc/http related issueand removed
on Aug 26, 2026 SeriousCoding789 commented
on Aug 27, 2026 ContributorAuthorMore actionsThe annotation-derived registry direction looks good to me and resolves the two-sources-of-truth concern. Before implementation, I think two points need to be made explicit:
- The latest reply says the independent old-route fixture should catch and preserve the five PBFT shielded endpoints. That conflicts with the issue body (including Compatibility item 3), which describes them as a missed 2020 deletion and says they will be removed. Current
upstream/developconfirms that these routes are still active only inHttpApiOnPBFTService, while the corresponding FullNode registrations are commented out. The parity fixture therefore needs to distinguish exact parity from reviewed, intentional deltas—for example, an explicit expected-diff/allowlist pinned by tests. Please choose remove or preserve and make the proposal, fixture, and generated matrix agree. - “Build and validate the whole table at class-load” is too early for the check that every annotated servlet is a Spring-managed bean. Metadata-only invariants (direct annotation, unique
(surface, path), non-blank paths, access/surface rules) can be checked statically, but bean resolution must happen after theApplicationContextis ready and beforeHttpService.start()binds Jetty. I suggest specifying two validation phases and adding a startup-order test proving that a context-validation failure leaves no HTTP port bound.
With those clarified, I support making the annotation the declaration source and the registry a derived, read-only audit view.
Thanks @lxcmyf — both worth pinning down.
1. Remove, not preserve. The issue body is correct and my earlier reply was not; sorry for the confusion. The five shielded endpoints are removed from the PBFT surface, aligning it with the other three where they have been disabled since 2020.
2. Two phases — agreed, and the wording is what misleads. What the registry validates on first touch is metadata only: directly-declared annotations, unique
(surface, path), non-blank path tokens, access/surface rules. The@Componentcheck belongs to that set — it inspects the annotation, it does not resolve a bean, and the registry never touches theApplicationContext.Bean resolution is separate and already later: servlets are resolved via
appContext.getBean(...)while the service mounts its endpoints, after the context is ready and before Jetty binds —HttpService.start()runsaddServlet(...)first, and the listener is only opened by the subsequentapiServer.start(). So a failure in either phase already leaves no port bound. But that ordering is implicit today, so I'll state the two phases explicitly and add the startup-order test you suggest.- The latest reply says the independent old-route fixture should catch and preserve the five PBFT shielded endpoints. That conflicts with the issue body (including Compatibility item 3), which describes them as a missed 2020 deletion and says they will be removed. Current
Thanks, that clarifies both points. Please update the issue body to reflect the removal decision and the two validation phases.
One compatibility issue still needs to be addressed:
RateLimiterServletregisters and looks up limiters usinggetClass().getSimpleName(). Today the FullNode, Solidity, and PBFT wrapper classes therefore have different limiter keys and independent limiter instances. After removing the wrappers, these surfaces will use the same base servlet class/key, so existing*OnSolidityServlet/*OnPBFTServletconfigurations may be silently ignored, and traffic on different ports may share one limiter.Please clarify whether the existing per-surface behavior will be preserved or treated as a configuration migration, and add coverage for it.
- linked a pull request that will close this issuefeat(API): refactor merge http servlets #6976
on Sep 18, 2026
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsNo status
Summary
java-tron currently maintains the same HTTP API surface four times: the default FullNode service, the Solidity service, the PBFT service and the standalone SolidityNode service. The Solidity and PBFT stacks consist of ~96 wrapper servlets whose only job is switching the per-thread read cursor, and every service keeps its own hand-written path-to-servlet registration list.
This proposal removes the duplication in two steps — both are part of this proposal; the ordering only serves a safe rollout:
Filterper cursor service, so all services share the single base servlet implementations (removes ~98 classes, ~2,900 lines, zero behavior change).@HttpApiannotation on the servlet that serves each endpoint (path suffix → access nature → exposed surfaces), from which a read-only registry is derived at class-load and every service builds its mappings; enforce at startup that non-read endpoints exist only on the FullNode surface.Problem
Motivation
Adding or changing one HTTP endpoint today requires synchronized edits in up to 4 wrapper classes and 4 registration lists. Missing one spot silently produces feature drift: the same endpoint behaves differently (or is missing) depending on which service serves it.
Current State
interfaceOnSolidity.httpcontains 49 wrapper servlets andinterfaceOnPBFT.httpcontains 47, each of the form:WalletOnSolidity/WalletOnPBFTextendWalletOnCursor;futureGetis a same-thread bracket:Chainbase.cursor) is aThreadLocal: it only selects the snapshot the current thread starts reading from, and is invisible to every other thread. Block processing, sync and broadcast run on dedicated executors that never touch the cursor, and each Jetty port owns a separate thread pool.SolidityNodeHttpApiServiceis a wholesale copy ofHttpApiOnSolidityService— the same wrapper servlets and registration, just with a different cursor wrapper. The PBFT service (HttpApiOnPBFTService) checks out the same way: verified endpoint by endpoint, it serves the same read-only interface as those two (bar the drift noted below), just with the PBFT cursor wrapper.Limitations or Risks
Maintenance burden: ~98 wrapper/forked classes are pure boilerplate that must track every base servlet change.
Feature drift is real, not hypothetical: the two SolidityNode forks missed later improvements of the base servlets — standard JSON error responses and
visible=truelog address conversion — so the same endpoint already answers differently across deployments:The endpoint sets of the Solidity and PBFT services also differ in ways documented nowhere: 2 read-only endpoints —
getpaginatednowwitnesslistandgettransactioninfobyblocknum— exist only on the Solidity service, and 5 dormant shielded-TRX endpoints existed only on the PBFT service:/walletpbft/getmerkletreevoucherinfoGetMerkleTreeVoucherInfoServlet/walletpbft/scanandmarknotebyivkScanAndMarkNoteByIvkServlet/walletpbft/scannotebyivkScanNoteByIvkServlet/walletpbft/scannotebyovkScanNoteByOvkServlet/walletpbft/isspendIsSpendServletThese trace to a 2020 cleanup that commented the shielded-TRX endpoints out on the other services but missed the PBFT service created five weeks earlier, and the leftover went unnoticed for six years because the PBFT service is disabled by default. Building the registry surfaced this immediately; the oversight is confirmed and fixed as part of this work.
Convention-only safety: nothing enforces that cursor services expose read-only endpoints. A registration mistake exposing a write endpoint on the Solidity or PBFT service would let a cursor-switched thread enter the write path (
Chainbase.putandSnapshotManager.advanceresolve through the cursor-awarehead()).Proposed Solution
Proposed Design
Step 1 — cursor filters (equivalent refactor).
One abstract filter reproduces the
futureGetbracket at the transport entry; two@Componentsubclasses parameterize the cursor:The filter is appended (after the existing
liteFnQueryHttpFilter/httpApiAccessFilter) at/*of the Solidity and PBFT contexts, so every request on a cursor service — including the/wallet/getnodeinfoalias on the Solidity service — reads the corresponding state view. The services then register the base servlet beans on unchanged path strings, and all wrapper servlets are deleted. Equivalence holds because the bracket is same-thread and always resets, exactly likefutureGet; the standalone SolidityNode service likewise drops its 2 forks in favor of the base servlets.One deliberate alignment is worth calling out: the
triggerconstantcontract/estimateenergywrappers wrapsuper.doPostin atry/catch (IOException)that logs and swallows the exception — not by design, but becausefutureGet(Runnable)takes a lambda that cannot throw checked exceptions. With the filter there is no lambda, so this workaround disappears: on the Solidity / PBFT services anIOExceptionfrom response writing now propagates to jetty exactly as it already does on the default FullNode service. The only affected scenario is a client that dropped the connection mid-response; the three services become uniform instead of subtly different.The read-only invariant. A thread whose cursor is set to SOLIDITY / PBFT must never enter a write path. The cursor does not only redirect reads:
Chainbase.put()/delete()andSnapshotManager.advance()/retreat()all resolve their target snapshot through the cursor-awarehead(), so a cursor-switched thread executing a write (e.g. a broadcast callingbuildSession()) would write into the solidified view or advance the shared snapshot chain from the wrong position — corrupting state that every thread sees. This is unchanged from the existing wrapper mechanism; the filter neither widens nor narrows it. Today the invariant holds because both cursor services expose only read-only endpoints (audited endpoint by endpoint: all handlers are pure queries, and the two constant-call endpoints buffer VM writes in an in-memoryRepositorythat is discarded without commit) — but it holds by convention only. The standalone SolidityNode surface carries the same read-only requirement for a different reason: that process only syncs solidified blocks from a FullNode and has no path to propagate a transaction into the network, so a write endpoint there could only strand transactions (today all of its 45 endpoints are read-only as well). The general rule — any non-read endpoint may exist only on the FullNode surface — is exactly what Step 2 turns into a boot-time check.Step 2 — endpoint registry derived from annotations (single source of truth).
Each endpoint is declared once, on the servlet that serves it:
HttpApiRegistryderives the whole table at class-load by scanningorg.tron.core.services.http.servletsforHttpServletsubclasses and reading their declared annotations. No hand-maintained list repeats the metadata: the declaration sits on the implementation, so an endpoint cannot drift between what it does and where it is registered. Each service'saddServletcollapses into one loop overHttpApiRegistry.forSurface(...), resolving servlet beans from the application context (which also removes the ~40 injected servlet fields per service).Two properties of the annotation carry the guarantee, and both are asserted at startup rather than left to review:
@HttpApiis not@Inherited, and is read only throughClass#getDeclaredAnnotation. Servlets here have historically been subclassed to vary behaviour — precisely what Step 1 deletes. If exposure were inheritable, or were looked up with a superclass-walking helper such as Spring'sAnnotatedElementUtils#findMergedAnnotation, a future subclass would silently inherit its parent's suffix, access and surfaces, and aWRITEendpoint could reach a cursor surface without anyone declaring it.@HttpApior@HttpApiExcluded. Without that rule a servlet could be added and simply never registered — the silent-omission failure this refactor exists to remove, merely moved from the registration lists into the annotations. Opting out therefore has to be written down, with a reason, on the class itself.The table is built and validated the first time the registry is touched — while a service mounts its servlets, before jetty binds any port, and across all four surfaces whether or not the node enables them. Any violation aborts the boot with
TronError(API_SERVER_INIT)rather than dropping an endpoint quietly:READendpoint declared on a surface other than FULL (covering both cursor services and the solidified-only standalone SolidityNode);(surface, suffix)pair;*would mount the servlet as a prefix wildcard swallowing every sibling endpoint under the same prefix;@Componentbean.Rollout is one service per commit — PBFT (most regular) → Solidity (handles the dual-mount alias) → standalone SolidityNode → FullNode (largest, carries all BUILD/WRITE rows) — each verified by a 1:1 endpoint diff against the previous hand-written list. The parity tests are then derived from the registry as well: each service's mounted path set must equal the registry's set for that surface, so an endpoint can be neither declared-but-unmounted nor mounted-but-undeclared.
Current shape: 120 servlets declare
@HttpApiand 10 declare@HttpApiExcluded; the derived surfaces are FULL 120, SOLIDITY 44, PBFT 44, SOLIDITY_NODE 44.The same declaration also lets an accidental gap in a read surface be closed. The two endpoints found only on the Solidity service —
getpaginatednowwitnesslistandgettransactioninfobyblocknum— are read-only and not surface-specific: each answers a general query against whatever snapshot the current cursor selects, with no behavior tied to a particular service. Read-only means they are safe to serve from a cursor service (no write-path risk); being general-purpose rather than dedicated interfaces means there is no functional reason to scope them to Solidity alone. They are therefore declared on the PBFT surface as well — not on a blanket rule that every read must appear on every surface (a read may legitimately be surface-specific), but because these two have no reason to be withheld from PBFT. This also keeps the two transports in step: the follow-up gRPC merge (see References) servesRpcApiServiceOnPBFTfrom the shared read service, which brings these same two methods onto the PBFT gRPC service by construction. Aligning the HTTP surface here means HTTP and gRPC expose the same PBFT read set instead of the gap reopening on the other transport.Key Changes
frameworkonly.WalletCursorFilter+SolidityCursorFilter/PbftCursorFilter(~60 lines); the@HttpApi/@HttpApiExcludedannotations and theHttpApiRegistryderived from them.@HttpApideclaration.http.fullNodePort/solidityPort/PBFTPortand enable switches keep their semantics).Impact
futureGet(same thread, guaranteed reset). Verified by concurrency tests crossing HEAD × SOLIDITY × PBFT requests and by registry-derived endpoint parity tests.httpApiAccessFilter/liteFnQueryHttpFilterbehavior is untouched; the read-only constraint of cursor services and of the solidified-only standalone SolidityNode is upgraded from review convention to a startup-enforced invariant.Compatibility
triggerconstantcontract/estimateenergyon the Solidity / PBFT services no longer swallowIOExceptionfrom response writing (the old wrappers had to catch it inside a lambda); they now behave exactly like the default FullNode service.visible=truelog address conversion — i.e., existing drift is fixed, not introduced.getpaginatednowwitnesslistandgettransactioninfobyblocknum— are now also served on the PBFT surface. The change is additive (existing paths and callers are untouched); both are read-only, general-purpose queries with no surface-specific behavior, so serving them on PBFT closes an accidental gap between the two cursor surfaces. It mirrors the follow-up gRPC merge, which serves the same two methods on the PBFT service by construction.References (Optional)
WalletOnCursor,WalletOnSolidity,WalletOnPBFT,Chainbase(ThreadLocalcursor),Manager#setCursor/resetCursor,SnapshotManager.WalletOnSolidity/WalletOnPBFTremain in use by the gRPC services and jsonrpc; they are removed only after the follow-up below.RpcApiServiceOnSolidity/RpcApiServiceOnPBFT(~980 lines offutureGetdelegation) can be merged the same way with aServerInterceptor; gRPC call lifecycle needs its own verification.Additional Notes