Motivation

July 15, 2026 · View on GitHub

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: Final
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.

Test Cases

The reference implementation (tronprotocol/java-tron#6780) covers the following, all gated on ALLOW_TVM_OSAKA:

Output-length behaviour

  • modLen == 0: unchanged on both forks — returns empty output (new byte[0] and the empty byte array are equivalent).
  • modLen > 0 with an all-zero modulus: pre-Osaka returns empty output; post-Osaka returns modLen zero bytes. Covered for at least modLen = 1 and modLen = 32.
  • Boundary: modLen at the TIP-7823 upper limit, so the two Osaka MODEXP changes are exercised together.

VM observability

  • The change is asserted from a contract context, not only the precompile's direct output: STATICCALL / CALL to 0x...05 followed by RETURNDATASIZE / RETURNDATACOPY, confirming the caller observes modLen bytes post-Osaka and 0 bytes pre-Osaka. A Solidity 0.8.25 VM-level reproduction is included.

Pricing and independence

  • Gas regression: identical calldata is priced identically across the pre/post-Osaka paths, confirming getEnergyForData is unchanged.
  • TIP-7823 length-limit independence: both the gated and ungated length-limit paths are exercised, confirming the limit check and the output-length canonicalization are orthogonal.

Differential cross-client vectors

  • Frozen (base, exp, mod, baseLen, expLen, modLen) fixtures — including the mod = 0, modLen > 0 cases — are taken through go-ethereum's bigModExp (core/vm/contracts.go) and Besu's BigIntegerModularExponentiationPrecompiledContract, asserting byte-for-byte output parity. Fixtures are tagged by fork: the post-Osaka path targets the geth/Besu output (modLen zero bytes — already EIP-198-conformant upstream), while the pre-Osaka path keeps its expected-empty output as an intentional legacy divergence.