Repository navigation
TIP-871: Canonicalize MODEXP output length when modulus is zero #871
Description
Activity
This proposal looks reasonable. The current
ModExpimplementation has the zero-modulus special case returningEMPTY_BYTE_ARRAY, while EIP-198 defines the output length as the modulus length. Gating the behavior behindALLOW_TVM_OSAKAalso makes sense becauseRETURNDATASIZE/RETURNDATACOPYcan observe the change.A few tests would make the TIP and implementation safer:
modLen == 0: unchanged, returns empty output.modLen > 0with all-zero modulus: pre-Osaka returns empty output; post-Osaka returnsmodLenzero bytes.- Cover at least
modLen = 1andmodLen = 32. - Verify the observable VM behavior through
STATICCALL/CALLto0x05, especiallyRETURNDATASIZE. - 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.
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 > 0with all-zero modulus — pre-Osaka returns empty, post-Osaka returnsmodLenzero bytes. This is the only behavioral change and is gated byALLOW_TVM_OSAKA.modLen = 1andmodLen = 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/CALLandRETURNDATASIZE— 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.
@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.
@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 themod = 0, modLen > 0cases this TIP targets — through go-ethereum'sbigModExp(core/vm/contracts.go) and Besu'sBigIntegerModularExponentiationPrecompiledContract, 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 > 0they returnmodLenzero 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 byALLOW_TVM_OSAKA" story unambiguous. - Boundary case. Agreed on including
modLen == TIP-7823 limitso the two Osaka changes are exercised together in the same vector set.
Net effect is what you want: any future refactor of
modExp.executeis caught by a single fixture diff rather than re-deriving the spec. I'll structure the PR's test resources this way.- Frozen cross-client fixtures. Pull
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 withmodLen > 0to returnmodLenzero bytes, gated behindALLOW_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 coveringRETURNDATASIZE/RETURNDATACOPYobservability, 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.
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 = 0withmodLenequal 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.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 = 0atmodLen = 0 / 1 / 32and the TIP-7823 upper-bound size, plus frozen differential fixtures run through geth'sbigModExpand Besu'sBigIntegerModularExponentiationPrecompiledContractagainst 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/expmust still yieldmodLenzero bytes post-Osaka (and empty pre-Osaka). This confirmsbase/expdon'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
modLencan 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.
- Non-zero base/exp with a zero modulus. Worth pinning explicitly: the output is keyed solely on the modulus being zero, so non-zero
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!
Simple Summary
Make the
modExpprecompile (0x...05) return a zero-filled byte array of lengthmodLenwhen 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 theRETURNDATASIZE/RETURNDATACOPYopcodes — rely on it beingmodLenbytes 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:
When the calldata declares
modLen > 0but the modulus bytes are all zero, java-tron produces0bytes of output while Ethereum producesmodLenzero bytes. ethereumj carried the identical bug and fixed it in commitd28ba5c("Fixed: modexp returns incorrect length with 0 modulus"); go-ethereum returnsLeftPadBytes([]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 aCALL/STATICCALLto0x...05copies 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
modLenbe the third length header parsed from the precompile calldata, as today.This change is gated by the
ALLOW_TVM_OSAKAhardfork flag (proposal id96) 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'sexecute, when the parsed modulus value is zero:VMConfig.allowTvmOsaka()is true,executereturnstruewith an output ofnew byte[modLen]— amodLen-length, all-zero byte array.executereturnstruewith the empty byte array, as today.All other behaviour is unchanged:
getEnergyForData) is not modified.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 modulusmathematically undefined, so the returned value is a convention rather than a computed result. EIP-198 nonetheless fixes the output length atmodLenunconditionally, and the established cross-client convention is to returnmodLenzero 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_OSAKAhardfork 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 > 0and an all-zero modulus value: post-activation they returnmodLenzero bytes instead of empty output. Calls withmodLen == 0are 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.