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,executereturnstruewith an output ofnew byte[modLen]— amodLen-length, all-zero byte array. - Otherwise (pre-Osaka),
executereturnstruewith 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-
modLenadjustment of themodPowresult, is unchanged. modLenis 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 > 0with an all-zero modulus: pre-Osaka returns empty output; post-Osaka returnsmodLenzero bytes. Covered for at leastmodLen = 1andmodLen = 32.- Boundary:
modLenat 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/CALLto0x...05followed byRETURNDATASIZE/RETURNDATACOPY, confirming the caller observesmodLenbytes post-Osaka and0bytes 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
getEnergyForDatais 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 themod = 0, modLen > 0cases — are taken through go-ethereum'sbigModExp(core/vm/contracts.go) and Besu'sBigIntegerModularExponentiationPrecompiledContract, asserting byte-for-byte output parity. Fixtures are tagged by fork: the post-Osaka path targets the geth/Besu output (modLenzero bytes — already EIP-198-conformant upstream), while the pre-Osaka path keeps its expected-empty output as an intentional legacy divergence.