Skip to content

[Feature] Deduplicate HTTP servlet stacks with cursor filters and a declarative endpoint registry #6922

Description

@Sunny6889

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:

  • Step 1 — cursor filters: replace all cursor-switching wrapper servlets with one servlet Filter per cursor service, so all services share the single base servlet implementations (removes ~98 classes, ~2,900 lines).
  • Step 2 — endpoint registry: replace the four hand-written registration lists with one declarative table (endpoint → servlet → access nature → exposed surfaces), from which 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.http contains 49 wrapper servlets and interfaceOnPBFT.http contains 47, each of the form:
public class GetAccountOnSolidityServlet extends GetAccountServlet {
  @Autowired private WalletOnSolidity walletOnSolidity;
  protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
    walletOnSolidity.futureGet(() -> super.doGet(req, resp));
  }
  protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
    walletOnSolidity.futureGet(() -> super.doPost(req, resp));
  }
}
  • WalletOnSolidity / WalletOnPBFT extend WalletOnCursor; futureGet is a same-thread bracket:
dbManager.setCursor(cursor);   // SOLIDITY or PBFT (PBFT offset computed per call)
try { run(); } finally { dbManager.resetCursor(); }
  • The underlying read cursor (Chainbase.cursor) is a ThreadLocal: 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.
  • The standalone SolidityNode's SolidityNodeHttpApiService is a wholesale copy of HttpApiOnSolidityService — 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.
  • Registration lists are hand-written per service: 123 mappings on the default FullNode service, 45 on the Solidity service, 47 on the PBFT service, 45 on the standalone service — 128 unique endpoints declared 260 times.

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=true log address conversion — so the same endpoint already answers differently across deployments:

    // fork (GetTransactionInfoByIdSolidityServlet): logs stay hex even when visible=true,
    // and errors are written as raw text
    response.getWriter().println(JsonFormat.printToString(transInfo, visible));
    ...
    } catch (Exception e) {
      response.getWriter().println(e.getMessage());
    }
    
    // base (GetTransactionInfoByIdServlet): converts log addresses for visible=true,
    // and errors go through the standard JSON error body
    transactionInfo = transactionInfo.toBuilder().clearLog()
        .addAllLog(Util.convertLogAddressToTronAddress(transactionInfo)).build();
    ...
    } catch (Exception e) {
      Util.processError(e, response);
    }

    The endpoint sets of the Solidity and PBFT services also differ in ways documented nowhere: 2 read-only endpoints — getpaginatednowwitnesslist and gettransactioninfobyblocknum — exist only on the Solidity service, and 5 dormant shielded-TRX endpoints existed only on the PBFT service:

    Path (on the PBFT service only) Servlet
    /walletpbft/getmerkletreevoucherinfo GetMerkleTreeVoucherInfoServlet
    /walletpbft/scanandmarknotebyivk ScanAndMarkNoteByIvkServlet
    /walletpbft/scannotebyivk ScanNoteByIvkServlet
    /walletpbft/scannotebyovk ScanNoteByOvkServlet
    /walletpbft/isspend IsSpendServlet

    These 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.put and SnapshotManager.advance resolve through the cursor-aware head()).

Proposed Solution

Proposed Design

Step 1 — cursor filters (equivalent refactor).

One abstract filter reproduces the futureGet bracket at the transport entry; two @Component subclasses parameterize the cursor:

public abstract class WalletCursorFilter implements Filter {
  public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
      throws IOException, ServletException {
    try {
      dbManager.setCursor(cursor);   // Solidy/PBFT offset computed inside Manager#setCursor per request
      chain.doFilter(req, resp);
    } finally {
      dbManager.resetCursor();       // always back to HEAD: no residue on pooled jetty threads
    }
  }
}

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/getnodeinfo alias 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 like futureGet; the standalone SolidityNode service likewise drops its 2 forks in favor of the base servlets.

One deliberate alignment is worth calling out: the triggerconstantcontract / estimateenergy wrappers wrap super.doPost in a try/catch (IOException) that logs and swallows the exception — not by design, but because futureGet(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 an IOException from 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() and SnapshotManager.advance()/retreat() all resolve their target snapshot through the cursor-aware head(), so a cursor-switched thread executing a write (e.g. a broadcast calling buildSession()) 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-memory Repository that 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 (single source of truth).

A declarative table describes each endpoint once:

public enum HttpApiDef {
  GET_ACCOUNT("getaccount", GetAccountServlet.class,
      Access.READ, Surface.FULL, Surface.SOLIDITY, Surface.PBFT, Surface.SOLIDITY_NODE),
  SCAN_NOTE_IVK("scannotebyivk", ScanNoteByIvkServlet.class,
      Access.READ, Surface.FULL, Surface.PBFT),                  // surface differences become explicit
  BROADCAST_TRANSACTION("broadcasttransaction", BroadcastServlet.class,
      Access.WRITE, Surface.FULL)                                // write endpoints stay FULL-only
  // ... one row per endpoint (123 in total)
}

Each service's addServlet collapses into one loop over HttpApiDef.forSurface(...), resolving servlet beans from the application context (which also removes the ~40 injected servlet fields per service). 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.

Finally the table is armed: at startup the node refuses to boot if any non-READ row declares a surface other than FULL (covering both cursor services and the solidified-only standalone SolidityNode), and the endpoint parity test is generated from the table instead of being maintained by hand.

The same table also lets an accidental gap in a read surface be closed. The two endpoints found only on the Solidity service — getpaginatednowwitnesslist and gettransactioninfobyblocknum — 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) serves RpcApiServiceOnPBFT from 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

  • Module: framework only.
    • New: WalletCursorFilter + SolidityCursorFilter / PbftCursorFilter (~60 lines); HttpApiDef registry.
    • Changed: the four HTTP service classes register base servlets (Step 1) and then consume the registry (Step 2).
    • Deleted: 96 cursor wrappers, 2 SolidityNode forks, and their orphan tests (~2,900 lines).
  • Configuration: none (http.fullNodePort / solidityPort / PBFTPort and enable switches keep their semantics).
  • API: external paths, ports and response semantics unchanged, except two read-only endpoints are additionally exposed on the PBFT surface (see Compatibility).

Impact

  • Developer Experience: an endpoint is implemented once and declared once; surface differences and access nature become reviewable data instead of tribal knowledge.
  • Stability: cursor mechanics are equivalent to the current futureGet (same thread, guaranteed reset). Verified by concurrency tests crossing HEAD × SOLIDITY × PBFT requests and by registry-generated endpoint parity tests.
  • Security: httpApiAccessFilter / liteFnQueryHttpFilter behavior 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.
  • Performance: one filter invocation replaces one wrapper method call per request — neutral.

Compatibility

  • Breaking Change: No — paths, ports and response formats are unchanged.
  • Default Behavior Change: No, with the following deliberate alignments:
    1. triggerconstantcontract / estimateenergy on the Solidity / PBFT services no longer swallow IOException from response writing (the old wrappers had to catch it inside a lambda); they now behave exactly like the default FullNode service.
    2. The two standalone SolidityNode endpoints gain the base servlets' standard JSON error format and visible=true log address conversion — i.e., existing drift is fixed, not introduced.
    3. The 5 dormant shielded-TRX endpoints are removed from the PBFT service (a confirmed missed deletion from the 2020 sweep; with the enabling committee parameter never activated, they could only return "not supported" errors).
    4. The two endpoints previously served only on the Solidity service — getpaginatednowwitnesslist and gettransactioninfobyblocknum — 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.
  • Migration Required: No.

References (Optional)

  • Involved classes: WalletOnCursor, WalletOnSolidity, WalletOnPBFT, Chainbase (ThreadLocal cursor), Manager#setCursor/resetCursor, SnapshotManager.
  • WalletOnSolidity / WalletOnPBFT remain in use by the gRPC services and jsonrpc; they are removed only after the follow-up below.
  • Follow-up (separate issue/PR): the gRPC twins RpcApiServiceOnSolidity / RpcApiServiceOnPBFT (~980 lines of futureGet delegation) can be merged the same way with a ServerInterceptor; gRPC call lifecycle needs its own verification.

Additional Notes

  • Do you have ideas regarding implementation? Yes
  • Are you willing to implement this feature? Yes

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions