Skip to content

TIP-871: Canonicalize MODEXP output length when modulus is zero #871

Description

@yanghang8612
tip: 871
title: Canonicalize MODEXP output length when modulus is zero
author: yanghang8612@163.com
discussions-to: https://github.com/tronprotocol/tips/issues/871
status: Last Call
type: Standards Track
category: VM
created: 2026-05-14

Simple Summary

Make the modExp precompile (0x...05) return a zero-filled byte array of length modLen when the modulus value is zero, instead of an empty byte array. This aligns the precompile's output length with EIP-198 and with every major Ethereum client.

Motivation

EIP-198 defines the MODEXP output as base^exp mod modulus, left-padded to exactly the byte length of the modulus (modLen, the third length header in the calldata). The output is a fixed-length value: callers — and the RETURNDATASIZE / RETURNDATACOPY opcodes — rely on it being modLen bytes regardless of the operand values.

The current java-tron implementation has a special case for a zero modulus value that returns an empty byte array:

// check if modulus is zero
if (isZero(mod)) {
  return Pair.of(true, EMPTY_BYTE_ARRAY);
}

When the calldata declares modLen > 0 but the modulus bytes are all zero, java-tron produces 0 bytes of output while Ethereum produces modLen zero bytes. ethereumj carried the identical bug and fixed it in commit d28ba5c ("Fixed: modexp returns incorrect length with 0 modulus"); go-ethereum returns LeftPadBytes([]byte{}, modLen) for the same case.

The effect is a consensus-relevant divergence from Ethereum: a contract that calls MODEXP with a zero modulus observes a different RETURNDATASIZE, and a CALL / STATICCALL to 0x...05 copies a different number of bytes into the caller's memory than it would on Ethereum. This breaks the fixed-length contract that EIP-198 consumers — common in on-chain cryptography libraries — depend on, and makes the precompile harder to reason about for SDKs, audits, and cross-chain tooling.

Specification

Let modLen be the third length header parsed from the precompile calldata, as today.

This change is gated by the ALLOW_TVM_OSAKA hardfork flag (proposal id 96) and ships as part of the Osaka upgrade, alongside the other Osaka VM changes (TIP-7883, TIP-854, TIP-7951, TIP-7939, EIP-7823). No new proposal id or config switch is introduced.

After activation, in modExp's execute, when the parsed modulus value is zero:

  • If VMConfig.allowTvmOsaka() is true, execute returns true with an output of new byte[modLen] — a modLen-length, all-zero byte array.
  • Otherwise (pre-Osaka), execute returns true with the empty byte array, as today.

All other behaviour is unchanged:

  • The energy cost (getEnergyForData) is not modified.
  • The non-zero-modulus path, including the existing left-pad-to-modLen adjustment of the modPow result, is unchanged.
  • modLen is bounded by the same calldata parsing that already governs the non-zero-modulus left-pad allocation, so no new allocation bound is introduced.

Rationale

A zero modulus makes base^exp mod modulus mathematically undefined, so the returned value is a convention rather than a computed result. EIP-198 nonetheless fixes the output length at modLen unconditionally, and the established cross-client convention is to return modLen zero bytes. Adopting that convention is the minimal change that removes the divergence; choosing any other value or length would itself be a fresh incompatibility.

The fix is deliberately scoped to the single isZero(mod) branch. The pricing formula and the non-zero path already satisfy EIP-198, so no other change is required.

Compatibility

This feature is gated behind the ALLOW_TVM_OSAKA hardfork flag and activates with the Osaka upgrade; it constitutes a hard fork. Pre-activation behaviour is byte-for-byte unchanged.

The only inputs whose observable behaviour changes are MODEXP calls with modLen > 0 and an all-zero modulus value: post-activation they return modLen zero bytes instead of empty output. Calls with modLen == 0 are unaffected (new byte[0] and the empty byte array are equivalent). Any contract that previously relied on the empty-output behaviour for a zero modulus would observe the new, EIP-198-conformant length after activation.

Activity

  1. lxcmyf commented on May 14, 2026

    @lxcmyf
    Contributor

    This proposal looks reasonable. The current ModExp implementation has the zero-modulus special case returning EMPTY_BYTE_ARRAY, while EIP-198 defines the output length as the modulus length. Gating the behavior behind ALLOW_TVM_OSAKA also makes sense because RETURNDATASIZE / RETURNDATACOPY can observe the change.

    A few tests would make the TIP and implementation safer:

    • modLen == 0: unchanged, returns empty output.
    • modLen > 0 with all-zero modulus: pre-Osaka returns empty output; post-Osaka returns modLen zero bytes.
    • Cover at least modLen = 1 and modLen = 32.
    • Verify the observable VM behavior through STATICCALL/CALL to 0x05, especially RETURNDATASIZE.
    • Confirm gas calculation is unchanged and the Osaka length-limit behavior remains independent from this fix.

    This seems like a small but useful compatibility fix, especially for cross-chain crypto libraries that assume EIP-198 fixed-length MODEXP output.

  2. yanghang8612 commented on May 15, 2026

    @yanghang8612
    ContributorAuthor

    Thanks @lxcmyf — the test matrix is exactly what should go into the PR. Quick confirmation on each:

    • modLen == 0 — unchanged, returns empty. (The shortcut at the top of the precompile catches this before the zero-modulus check.)
    • modLen > 0 with all-zero modulus — pre-Osaka returns empty, post-Osaka returns modLen zero bytes. This is the only behavioral change and is gated by ALLOW_TVM_OSAKA.
    • modLen = 1 and modLen = 32 — both covered; I'll also add a case near the upper bound TIP-7823 introduces, to confirm the two changes interact cleanly.
    • VM observability via STATICCALL / CALL and RETURNDATASIZE — agreed, will assert from a contract context rather than only the precompile's direct output.
    • Gas calculation — unchanged (gas depends only on length fields and the exponent's complexity, not the modulus value). Will pin this with a regression test that compares gas across pre/post-Osaka with identical calldata.
    • Osaka length-limit (TIP-7823) independence — confirmed; the limit check and this output-length canonicalization are orthogonal, and tests will exercise both gated and ungated paths.

    Will get a PR up with the implementation + this test matrix shortly.

  3. waynercheung commented on May 15, 2026

    @waynercheung

    @yanghang8612 +1 to the direction.
    One thing I'd add to the test matrix beyond input shape coverage: differential vectors against go-ethereum and Besu, not just hand-written assertions.

    The whole point of this TIP is consensus parity with EIP-198, so the strongest signal is "given the same calldata, java-tron's output bytes == geth's output bytes." Concretely:

    • Pull a handful of (base, exp, mod=0, modLen) vectors through core/vm/contracts.go's bigModExp.Run (or Besu's BigIntegerModularExponentiationPrecompiledContract) and freeze the outputs as fixtures in our test resources.
    • Include the boundary case modLen == TIP-7823 limit so the two Osaka changes are exercised together.

    That way any future refactor of modExp.execute is caught by a single fixture diff rather than relying on us re-deriving the spec.

  4. yanghang8612 commented on May 21, 2026

    @yanghang8612
    ContributorAuthor

    @waynercheung great point — differential vectors are exactly the right anchor for a consensus-parity TIP, and stronger than hand-written assertions. Folding this into the test plan:

    • Frozen cross-client fixtures. Pull (base, exp, mod, baseLen, expLen, modLen) vectors — including the mod = 0, modLen > 0 cases this TIP targets — through go-ethereum's bigModExp (core/vm/contracts.go) and Besu's BigIntegerModularExponentiationPrecompiledContract, and freeze the outputs as fixtures in the test resources. The post-Osaka assertion becomes the refactor-proof check you described: given identical calldata, java-tron's output bytes == geth/Besu's output bytes.
    • Fork tagging. One subtlety to make explicit: geth and Besu already implement EIP-198 fixed-length output, so for mod = 0, modLen > 0 they return modLen zero bytes today — which is exactly the post-Osaka behavior we're aligning to, so those fixtures are the parity target. The pre-Osaka java-tron output (empty bytes) is an intentional legacy divergence, so fixtures will be tagged by fork and the pre-Osaka path keeps its own expected-empty vectors. Keeps the "behavior changed, gated by ALLOW_TVM_OSAKA" story unambiguous.
    • Boundary case. Agreed on including modLen == TIP-7823 limit so the two Osaka changes are exercised together in the same vector set.

    Net effect is what you want: any future refactor of modExp.execute is caught by a single fixture diff rather than re-deriving the spec. I'll structure the PR's test resources this way.

  5. yanghang8612 commented on Jun 5, 2026

    @yanghang8612
    ContributorAuthor

    Status update: implementation merged → moving to Last Call.

    The reference implementation has landed in java-tron via tronprotocol/java-tron#6780 (merged into release_v4.8.2). It canonicalizes MODEXP output for an all-zero modulus with modLen > 0 to return modLen zero bytes, gated behind ALLOW_TVM_OSAKA, and ships with the test matrix discussed in this thread — direct Osaka MODEXP cases plus a Solidity 0.8.25 VM-level reproduction covering RETURNDATASIZE / RETURNDATACOPY observability, with gas unchanged and the TIP-7823 length-limit path exercised independently.

    With spec, behavior, and implementation now aligned and the cross-client output parity (geth / Besu) confirmed, I'm moving TIP-871 to Last Call. Please raise any remaining concerns on the spec or the gated behavior here before it advances.

  6. Redchar1992 commented on Jun 5, 2026

    @Redchar1992

    Support moving this through Last Call now that the implementation has landed.

    For the final test checklist, I would like to see frozen fixtures for the edge cases most likely to cause consensus drift: mod = 0 with modLen equal to 0, 1, 32, and the upper-bound size; non-zero base/exp with zero modulus; and malformed calldata where the declared length headers exceed available input. Differential vectors against geth/Besu plus java-tron are especially valuable here because the purpose of this TIP is byte-for-byte EIP-198 parity.

  7. yanghang8612 commented on Jun 9, 2026

    @yanghang8612
    ContributorAuthor

    Thanks @Redchar1992. Most of that checklist is already captured in the Test Cases section now merged with the TIP (tip-871.md, PR #888) and in the reference implementation (java-tron#6780): mod = 0 at modLen = 0 / 1 / 32 and the TIP-7823 upper-bound size, plus frozen differential fixtures run through geth's bigModExp and Besu's BigIntegerModularExponentiationPrecompiledContract against java-tron, fork-tagged.

    Your two additions are good and I'll fold them into the fixture set:

    • Non-zero base/exp with a zero modulus. Worth pinning explicitly: the output is keyed solely on the modulus being zero, so non-zero base/exp must still yield modLen zero bytes post-Osaka (and empty pre-Osaka). This confirms base/exp don't leak into the zero-modulus output path.
    • Over-declared length headers (declared length exceeds available input). Good edge case, and it interacts with this change: per EIP-198, operand bytes beyond the supplied calldata are zero-filled, so an over-declared modLen can itself produce an all-zero modulus — landing directly on this TIP's branch. A fixture here confirms the parser's zero-padding is unchanged and composes correctly with the new output-length rule.

    These don't change the spec text (parsing/padding is unchanged), so they go in as additional fixtures rather than a revision. With the implementation merged and the TIP now in-repo, this stays in Last Call for the review window.

  8. yanghang8612 commented on Jul 16, 2026

    @yanghang8612
    ContributorAuthor

    Thank you for your continued interest and support for this TIP.

    All relevant features have now been implemented in the latest release: java-tron GreatVoyage-v4.8.2.

    The specification has also been finalized: TIP-871 is now in Final status.

    As the objectives of this issue have been achieved, we will now close this thread. Thank you all for your valuable contributions to the TRON ecosystem!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions