Skip to content

TIP-854: Canonicalize calldata for signature-verification precompiles #854

@yanghang8612

Description

@yanghang8612
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.

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