tip: 854
title: Canonicalize calldata for signature-verification precompiles
author: yanghang8612@163.com
discussions-to: https://github.com/tronprotocol/tips/issues/854
status: Draft
type: Standards Track
category: VM
created: 2026-04-12
Simple Summary
Canonicalize the calldata of the two signature-verification precompiles (batchValidateSign at 0x...09, validateMultiSign at 0x...0a): reject calldata whose byte length does not match the shape the per-call energy cost already assumes when pricing the call. On reject, the precompile's execute returns false with empty output; the invoking call frame — reachable through any of CALL / CALLTOKEN / STATICCALL / DELEGATECALL / CALLCODE — consumes its pre-allocated energy, the stack receives 0, memory receives no return data, and the outer transaction continues with its remaining budget intact.
Motivation
These two precompiles charge energy under a fixed shape assumption: the per-call energy cost is derived from a formula that treats the calldata as a static head followed by exactly N equally-sized tail items. The current execution path does not enforce the same shape before decoding — the decoder follows whatever offsets the calldata supplies and zero-pads any missing bytes through Arrays.copyOfRange. The set of byte strings the precompile actually accepts is therefore a superset of the shapes the pricing formula has ever been evaluated for, and many distinct byte strings can represent the same logical call: non-word-aligned calldata has its trailing sub-word bytes silently dropped at parse time, calldata shorter than the static head is zero-padded out, and calldata whose tail length does not decompose into an integer number of items still flows through the decoder.
The effect is that the set of inputs these precompiles accept is larger than the documented interface suggests, which makes them harder to reason about for wallets, SDKs, indexers, audits, and formal specifications. This TIP closes the gap by rejecting calldata whose byte length is incompatible with the shape the pricing formula already assumes. The set of accepted inputs then collapses to exactly the shapes pricing has been computed for.
These two precompiles happen to use Solidity ABI encoding, but the TIP does not claim general Solidity-ABI canonicalisation as the reference. The reference is specifically the shape the existing energy-cost formula already bakes in.
Specification
Let W = 32. For each precompile, let H be the number of static head words it declares, and I the number of words consumed per element of its tail array. H and I are exactly the offset and divisor already present in the per-call energy cost as (words - H) / I:
| Precompile |
H |
I |
validateMultiSign |
5 |
5 |
batchValidateSign |
5 |
6 |
After activation, at the top of each precompile's execute entry:
- If
data == null, or data.length % W != 0, or data.length < H * W, or (data.length - H * W) % (I * W) != 0, execute returns false with empty output, without invoking the decoder and without performing any ecrecover. From the caller's perspective, the invoking call frame consumes its pre-allocated energy, the stack receives 0, memory receives no return data, and the outer transaction continues.
- Otherwise, behaviour is identical to the current implementation.
The per-call energy cost itself is unchanged, and its value on rejected calldata is not observable to the caller: the rejection path is a failed-execution return, and the runtime never evaluates the success-branch refund that would otherwise subtract the cost.
Rationale
The pricing formula has always assumed calldata is a static head followed by an integer number of equally-sized tail items. What this TIP fixes is that execute did not previously enforce the same assumption at runtime. The check is deliberately restricted to exactly what that formula implies:
- Non-multiple-of-32 length: the word-level parser silently drops trailing sub-word bytes, so such input cannot represent an integer number of header-plus-items words for any
N.
- Fewer than
H words: the static head is not fully present.
(data.length - H*W) mod (I*W) != 0: the tail cannot be parsed as exactly N items of size I words.
Validation of inner dynamic offsets, full-shape abi.encode conformance, and any further decoder hardening are out of scope for this TIP. They can be addressed in a follow-up if the community wants stronger containment.
As a side observation, the length check also closes one specific path through the decoder — inputs shorter than the static head — that today raises a RuntimeException during word-array access. Length-valid inputs with malformed inner offsets can still cause the decoder to dereference past the end of the parsed word array and are not addressed by this TIP.
Compatibility
This feature is gated behind a hardfork flag and constitutes a hard fork. Pre-activation behaviour, including the per-call energy cost, is byte-for-byte unchanged.
For any calldata whose byte length already satisfies data.length == H*W + I*W*N for some non-negative N (the shape pricing has been assuming all along), the new rule is a no-op. The only inputs whose observable behaviour changes are those whose byte length is incompatible with that formula.
Simple Summary
Canonicalize the calldata of the two signature-verification precompiles (
batchValidateSignat0x...09,validateMultiSignat0x...0a): reject calldata whose byte length does not match the shape the per-call energy cost already assumes when pricing the call. On reject, the precompile'sexecutereturnsfalsewith empty output; the invoking call frame — reachable through any ofCALL/CALLTOKEN/STATICCALL/DELEGATECALL/CALLCODE— consumes its pre-allocated energy, the stack receives0, memory receives no return data, and the outer transaction continues with its remaining budget intact.Motivation
These two precompiles charge energy under a fixed shape assumption: the per-call energy cost is derived from a formula that treats the calldata as a static head followed by exactly
Nequally-sized tail items. The current execution path does not enforce the same shape before decoding — the decoder follows whatever offsets the calldata supplies and zero-pads any missing bytes throughArrays.copyOfRange. The set of byte strings the precompile actually accepts is therefore a superset of the shapes the pricing formula has ever been evaluated for, and many distinct byte strings can represent the same logical call: non-word-aligned calldata has its trailing sub-word bytes silently dropped at parse time, calldata shorter than the static head is zero-padded out, and calldata whose tail length does not decompose into an integer number of items still flows through the decoder.The effect is that the set of inputs these precompiles accept is larger than the documented interface suggests, which makes them harder to reason about for wallets, SDKs, indexers, audits, and formal specifications. This TIP closes the gap by rejecting calldata whose byte length is incompatible with the shape the pricing formula already assumes. The set of accepted inputs then collapses to exactly the shapes pricing has been computed for.
These two precompiles happen to use Solidity ABI encoding, but the TIP does not claim general Solidity-ABI canonicalisation as the reference. The reference is specifically the shape the existing energy-cost formula already bakes in.
Specification
Let
W = 32. For each precompile, letHbe the number of static head words it declares, andIthe number of words consumed per element of its tail array.HandIare exactly the offset and divisor already present in the per-call energy cost as(words - H) / I:HIvalidateMultiSignbatchValidateSignAfter activation, at the top of each precompile's
executeentry:data == null, ordata.length % W != 0, ordata.length < H * W, or(data.length - H * W) % (I * W) != 0,executereturnsfalsewith empty output, without invoking the decoder and without performing anyecrecover. From the caller's perspective, the invoking call frame consumes its pre-allocated energy, the stack receives0, memory receives no return data, and the outer transaction continues.The per-call energy cost itself is unchanged, and its value on rejected calldata is not observable to the caller: the rejection path is a failed-execution return, and the runtime never evaluates the success-branch refund that would otherwise subtract the cost.
Rationale
The pricing formula has always assumed calldata is a static head followed by an integer number of equally-sized tail items. What this TIP fixes is that
executedid not previously enforce the same assumption at runtime. The check is deliberately restricted to exactly what that formula implies:N.Hwords: the static head is not fully present.(data.length - H*W) mod (I*W) != 0: the tail cannot be parsed as exactlyNitems of sizeIwords.Validation of inner dynamic offsets, full-shape
abi.encodeconformance, and any further decoder hardening are out of scope for this TIP. They can be addressed in a follow-up if the community wants stronger containment.As a side observation, the length check also closes one specific path through the decoder — inputs shorter than the static head — that today raises a
RuntimeExceptionduring word-array access. Length-valid inputs with malformed inner offsets can still cause the decoder to dereference past the end of the parsed word array and are not addressed by this TIP.Compatibility
This feature is gated behind a hardfork flag and constitutes a hard fork. Pre-activation behaviour, including the per-call energy cost, is byte-for-byte unchanged.
For any calldata whose byte length already satisfies
data.length == H*W + I*W*Nfor some non-negativeN(the shape pricing has been assuming all along), the new rule is a no-op. The only inputs whose observable behaviour changes are those whose byte length is incompatible with that formula.