Skip to content

feat(abi): support ABI v2 structs and nested arrays#212

Merged
kuny0707 merged 26 commits into
tronprotocol:release_1.0.0from
0xbigapple:feature/support-dynamics-abi
Jul 21, 2026
Merged

feat(abi): support ABI v2 structs and nested arrays#212
kuny0707 merged 26 commits into
tronprotocol:release_1.0.0from
0xbigapple:feature/support-dynamics-abi

Conversation

@0xbigapple

@0xbigapple 0xbigapple commented Mar 26, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Extends ABI v2 (tuple/struct) support in the abi module across encoding and decoding, ported from and enhanced over web3j v6.0.0:

  • Structs: encode/decode StaticStruct and DynamicStruct, including nested structs, arrays of structs, and nested array layouts represented through runtime TypeReference/innerTypes. Constructor-reflection decoding supports one array element-type level through the new @Parameterized annotation, which restores the erased element class for array-typed constructor parameters (the static-array size is derived from the concrete StaticArrayN class); nested array fields must use runtime type references.
  • Arrays: static arrays (StaticArrayN, plus the newly generated StaticArray0) and dynamic arrays with nested static/dynamic elements. Fixes missing offset pointers when a DynamicArray contains dynamic StaticArray elements.
  • Packed encoding: adds TypeEncoder.encodePacked and FunctionEncoder.encodeConstructorPacked for supported scalar and array shapes, with explicit rejection of directly supplied structs, declared nested-array component types, and arrays declared with a supertype component (e.g. Type.class) whose actual elements are unsupported (both component-type and per-element validation).
  • Custom errors: new CustomError datatype and CustomErrorEncoder for encoding Solidity error definitions and computing their signature hashes.

Bug fixes and hardening included along the way:

  • Utf8String padded-length calculation now uses the UTF-8 byte length instead of the character count, so non-ASCII strings encode correctly; the arithmetic is overflow-checked and length-word semantics match web3j.
  • Struct signatures now serialize nested tuple types correctly, and unsupported nested-array shapes are rejected explicitly instead of producing wrong output.
  • Decode paths validate TypeReference nesting depth at the decoder entry. Reflective loading of externally supplied ABI type names avoids class initialization and validates that resolved classes implement the ABI Type interface.
  • Silent fallbacks are replaced with explicit errors, for example when building a Solidity type string for an empty array whose generic struct or nested-array element type cannot be determined; when a struct field uses raw StaticArray instead of a generated StaticArrayN; when an array-typed struct field lacks @Parameterized (previously emitted invalid signature tokens like dynamicarray, yielding wrong selectors); and when @Parameterized is placed on a non-array field.
  • Bare base classes Uint/Int/Ufixed/Fixed used as array/struct components now canonicalize to their sized tokens (e.g. uint[]uint256[]), matching solc's alias expansion before selector hashing and removing the inconsistency between the two class-to-token mappings.
  • All case conversions are pinned to Locale.ROOT, so signature tokens survive locales like Turkish (where Int256 used to corrupt into ınt256).
  • The TrcToken signature token is corrected from the wrong trcToken256 (instance path) / trctoken (TypeReference path) to the canonical trcToken, so selectors of trcToken-parameter functions and events change accordingly — the previous selectors matched no real contract. TrcToken also now extends Uint: decoding takes the unsigned branch, so a word with the top bit set yields the positive token id (previously a two's-complement negative), and encoding values >= 2^255 no longer throws.

Why are these changes required?

Contracts compiled with pragma abicoder v2 (the default since Solidity 0.8) commonly use structs and nested arrays in function parameters, return values, and events. Trident already supported basic struct and array cases, but several ABI v2 combinations — such as runtime tuples, arrays of structs, nested array layouts, and array-typed struct fields — were incomplete or inconsistent across encoding, return decoding, and event-signature generation. This PR completes those paths.

This PR has been tested by:

  • Unit tests: extensive new coverage in abi — encoder/decoder tests for structs and nested arrays (DefaultFunctionEncoderTest, FunctionReturnDecoderTest, TypeDecoderTest, TypeEncoderTest), packed encoding (TypeEncoderPackedTest), constructor-reflection decoding (StructFieldReflectionDecodeTest, StaticArrayInStructDecodeTest), custom errors, and datatype-level tests (Utf8StringTest, DynamicArrayTest, StaticStructTest, etc.).
  • Compatibility tests: TridentAbiEncodeDecodeCompatibilityTest replays large fixture datasets (contract-interface*.json.gz) to verify encode/decode output stays consistent for real-world ABI inputs.
  • Nile integration test: AbiV2ContractTest in core calls a pre-deployed ABI v2 contract on Nile to verify static/dynamic structs, arrays of structs, multidimensional arrays, and packed encoding. The deployment case and the signed nested-struct transaction case remain disabled/manual.
  • Manual testing on Nile against the pre-deployed contract.

Follow up

Known limitations, deliberately kept aligned with the web3j implementation (documented in code comments) so they can be fixed upstream first and then synced back:

  • Constructor-reflection struct fields can express only one array element-type level; nested array fields require runtime TypeReference/innerTypes.
  • Address(Uint) is not normalized to 160 bits and can produce a non-canonical packed address; the constructor is now marked @Deprecated. Address(BigInteger)/Address(String) produce the canonical 20 bytes.
  • Other web3j-inherited pitfalls are noted in code comments and left as-is to keep the diff against web3j minimal.

Extra details

  • Compatibility note: the new FunctionEncoder methods encodeWithSelector and encodePackedParameters are optional overrides with throwing defaults (UnsupportedOperationException): existing third-party subclasses keep compiling and serving the original entry points unchanged; only calling the new entry points without an override fails, with a clear message. Encoder/decoder providers are now resolved once during class initialization instead of per call, so providers registered afterwards are not picked up.
  • jackson-databind is added as a test-only dependency of the abi module (for reading JSON fixtures); no new runtime dependencies.
  • Legacy array constructors are marked @Deprecated with Javadoc pointing to the type-safe replacements.

…odeWithOutPrefix/buildEventSignatureWithOutPrefix
…ray elements

When encoding DynamicArray<StaticArray<DynamicBytes>> (e.g. bytes[3][]),the encoder was missing offset pointer table for inner elements.StaticArray with dynamic sub-elements was not recognized as needing offsets in encodeArrayValuesOffsets()
@0xbigapple 0xbigapple closed this Apr 24, 2026
  - Utils.getStructType: @parameterized on StaticArrayN struct fields now
    correctly emits "foo[N]" instead of collapsing to "foo[]". Throws
    RuntimeException on ClassNotFoundException instead of silent fallback;
    extractStaticArraySize now takes Class<?> and validates that the class
    is a generated StaticArrayN subclass (rejects raw StaticArray.class
    with a guidance message).

  - Utils.getParameterizedTypeName: build canonical ABI names for nested
    arrays via the new getArrayElementTypeName helper, which recurses on
    TypeReference rather than collapsing the inner type to a Class. The
    old path produced non-canonical strings like "staticarray2[]".

  - DefaultFunctionReturnDecoder.getDataOffset: include DynamicStruct in
    the dynamic-offset detection. The hand-rolled OR-chain was missing
    it, causing DynamicStruct return values to be decoded from the wrong
    offset.

  - DynamicArray.getTypeAsString: throw when an empty array's component
    type is the generic DynamicStruct/StaticStruct base or a raw Array
    subclass — Java type erasure cannot recover the inner element type
    in those cases. Concrete StructType subclasses still resolve through
    Utils.getStructType.

  - Utf8String.bytes32PaddedLength: guard against null value, matching
    the null-tolerance already in equals()/hashCode().

  - Array: remove the unused getNativeValueCopy() method. It was dead
    code in this PR (zero callers) and only unwrapped one level, so
    nested arrays/structs still contained ABI Type objects rather than
    native values.

  - UtilsTest: positive/negative cases for issue tronprotocol#20, DynamicArray
    regression guard, and canonical nested array type names
    (uint256[2][], uint256[2][2]).
  - EventEncoderTest: nested static array method signature.
  - DynamicArrayTest: empty concrete struct array (allowed),
    empty raw Array subclass (throws), empty generic struct array
    (throws).
  - Utf8StringTest: bytes32PaddedLength with null value.
  - CustomErrorTest: assert parameter count before per-element
    comparison; the previous loop could pass silently if getParameters()
    returned fewer elements than expected.
  - TridentAbiEncodeDecodeCompatibilityTest.isSignedIntOutOfRange:
    treat bare `int` as ABI alias for int256.
@0xbigapple 0xbigapple reopened this May 14, 2026
0xbigapple and others added 5 commits July 13, 2026 13:54
…plify DynamicStruct type-string branches
- decode trcToken[N] static arrays: instantiateStaticArray used the
  deprecated List-only StaticArrayN constructor, which re-derives the
  element type via AbiTypes.getType(getTypeAsString()); TrcToken's type
  string is "trcToken256" while its AbiTypes key is "trcToken", so
  decoding trcToken[N] failed on every path. Switch to the Class-carrying
  constructor and take the element type from the decoded elements.

- fail fast on truncated input in decodeNumeric: Arrays.copyOfRange
  silently zero-pads past the end of input, and the padding lands in the
  low-order bytes — a truncated Uint256 word decodes to
  value << (8 * missing bytes) with no error. Restore the fail-fast
  contract with a JDK 8-compatible explicit bounds check.

- keep T[0] on the static-array branch in instantiateArrayType: arraySize
  is -1 only for true dynamic-array references, but the dispatch tested
  <= 0, so uint256[0] silently instantiated as DynamicArray with type
  string "uint256[]" — a wrong function selector. With < 0, T[0]
  constructs a proper StaticArray0 and rejects non-empty values loudly.

- surface type-resolution failures in isDynamic(TypeReference): the
  method swallowed ClassNotFoundException/ClassCastException and answered
  "static", silently mis-placing every subsequent field's offset. Exclude
  StructType from the array branch explicitly (StaticStruct extends
  StaticArray), then rethrow resolution failures as
  UnsupportedOperationException.
Three defects in the constructor-reflection struct decoding path:

1. StaticArrayN struct fields were undecodable: they fell through to
   decode(String, int, Class), which rejects every Array subclass. New
   decodeStaticArrayStructField reads the element type from @parameterized
   (the same contract getStructType uses for signatures), builds the array
   reference, and decodes via decodeStaticArray. Static arrays of dynamic
   elements get a clear error pointing at innerTypes instead of garbage.

2. Nested-struct spans were guessed from reflection metadata —
   getDeclaredFields()[i]/getConstructors()[0] parameter counts in
   decodeStaticStructElement (wrong for non-public or extra java fields,
   arbitrary constructor pick, one-word-per-parameter assumption breaking
   doubly-nested structs), and the public-fields flat list in
   decodeDynamicStructElements (undercounts private fields). Both now
   advance by the decoded value's real span (bytes32PaddedLength), the
   pattern decodeArrayElements already used.

3. getTypeReferenceForParameterizedField round-tripped StaticArrayN element
   types through a lossy Solidity-name string (getSimpleTypeName lowercases,
   AbiTypes' lookup is case-sensitive), so TrcToken/Fixed/struct elements
   threw "Unsupported type encountered". It now builds the
   StaticArrayTypeReference directly from the classes at hand;
   getSimpleTypeName gains the camelCase trcToken token for signatures.
…lection path

string[2]-style struct fields are ABI-dynamic (head offset + tail) and already
encode correctly, but decodeDynamicStructElements misclassified them as static
and threw. Classify fields via isDynamicStructField (declared class OR
@parameterized element type) at all three decision points, and decode the tail
with the same machinery the innerTypes path uses. Add regression tests; assert
zero-length static array rejection at the construction layer per review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed nested arrays

Address three code-review findings on the runtime-tuple and nested-array paths:

- Utils.getTypeName: serialize runtime tuple TypeReferences (innerTypes form)
  as "(type1,type2,...)" so EventEncoder and CustomErrorEncoder can build
  signatures like E((uint256,string)) without a generated struct class.
  Previously this form hit constructor reflection on the DynamicStruct or
  StaticStruct base class and threw. Composes with arrays via
  getSubTypeReference, e.g. E((uint256,string)[]).

- Utils.getTypeReferenceForParameterizedField: fail fast when the
  @parameterized element type is itself a (non-struct) array class. The
  annotation carries a single element-type level, so nested array fields
  (e.g. uint256[2][2] as StaticArray2<StaticArray2<Uint256>>) previously
  produced invalid signatures like "(staticarray2[2])", threw
  ClassCastException, or silently misread tail data during decode.
  Documented as a known limitation of the constructor-reflection path;
  runtime innerTypes references (makeTypeReference) still support nesting.
  TypeDecoder.decodeDynamicParameterFromStruct now routes its DynamicArray
  branch through the same guard.

- TypeEncoder.isSupportingEncodedPacked: reject StaticArray component types.
  Solidity forbids nested arrays in abi.encodePacked; previously the encoder
  silently fell back to standard ABI encoding, emitting offset/length words
  that hash to wrong signatures. Checking the direct component type covers
  all nesting shapes since a nested array's component is itself an array.

Adds regression tests across EventEncoderTest, CustomErrorEncoderTest,
UtilsTest and TypeEncoderPackedTest.
- encodePacked: reject structs up front. DynamicStruct/StaticStruct
  extend the array classes and fell into arrayEncodePacked, silently
  producing corrupt packed bytes; Solidity's packed mode does not
  support structs.
- trcToken: emit the canonical camelCase "trcToken" from both
  AbiTypes.getTypeAString and instance-level TrcToken.getTypeAsString
  (previously "trctoken" / "trcToken256"), so selectors and the
  getType round-trip agree on every path; the override mirrors Address
  over its underlying Uint160.
- decoding: bound decode()'s numeric/address dispatch slices to one
  32-byte word and pass offsets instead of whole-tail substrings in the
  dynamic-struct paths. Decoding uint256[n] copied O(n^2) characters:
  uint256[16000] (1 MiB hex) took ~8.4s and now ~28ms. Chain responses
  are untrusted input, so this was a DoS amplification vector.
- StaticArray.getTypeAsString: handle empty struct components the way
  DynamicArray already does — tuple string via Utils.getStructType,
  descriptive error for generic struct / nested array / null component —
  instead of silently hashing lowercased Java class names into
  selectors.
- TypeReference depth validation: allow exactly MAX_TYPEREF_DEPTH
  nesting (off-by-one) and replace the iterative walker plus cycle set
  with bounded recursion; cycles necessarily trip the depth cap.
- consolidate StaticArrayN size parsing onto Utils.extractStaticArraySize,
  preferring StaticArrayTypeReference.getSize() where available;
  decoding uint256[33][2] now fails naming the missing generated
  StaticArray33 instead of a misleading zero-length-array error.
- TypeReference.getSubTypeReference: make the base declaration public
  (matching current upstream web3j) so subclasses outside the package
  can actually override it — package-private meant external
  same-signature methods only shadowed it, so wrapping a runtime-tuple
  innerTypes reference in a caller-built array reference decoded against
  a null subtype and threw.
- decode(String, int, TypeReference): every call throws.
  getRawType().getClass() always yields java.lang.Class, so both
  isAssignableFrom branches are unreachable. Inherited from web3j and
  never called inside the SDK; document the defect, the working
  alternatives (decodeStaticArray / decodeDynamicArray) and the intended
  fix instead of changing behavior.
- Address(Uint) packed width: encodePacked slices Address by the stored
  Uint's bit size, so only the 160-bit-normalizing constructors
  (Address(BigInteger), Address(String)) produce the canonical 20 packed
  bytes, while Address(Uint) packs to 32. Document the pitfall at the
  constructor and at the removePadding slice.
- route StaticArrayN size parsing through Utils.extractStaticArraySize
- collapse getDataOffset's dynamic check onto TypeDecoder.isDynamic
- state decodeNumeric's length check in word terms
- extract shared element-type-string logic into Array
- dedupe component-type inference in deprecated array constructors
- reuse TypeDecoder.isDynamic for TypeEncoder's empty static-array probe
- wrap two over-length lines in TypeDecoder.decode
… semantics

Return the length word plus the value padded to 32-byte words, matching
current web3j (null value still tolerated as empty). Port web3j's
Utf8String encoding and padded-length tests.
…ce); harden Utf8String padded length

- declare ClassNotFoundException on TypeDecoder.isDynamic(TypeReference) and drop
  the CNFE-to-UnsupportedOperationException wrapper so getDataOffset honors its
  checked throws contract; add regression tests covering unresolvable type
  references on both the getDataOffset and FunctionReturnDecoder.decode paths
- guard Utf8String.bytes32PaddedLength arithmetic with Math.addExact/multiplyExact
- re-enable testDecodeMultiDimDynamicArrayFunction, previously commented out when
  multi-dimensional dynamic arrays were unsupported
- test cleanups: reuse TypeEncoder.isDynamic in the compatibility test, drop a
  redundant anonymous DynamicArray subclass, remove unused imports, fix a javadoc
  typo, construct the test mapper via JsonMapper
@0xbigapple 0xbigapple changed the title feat(abi): support abiV2 include static/dynamics arrays, struct feat(abi): support ABI v2 structs and nested arrays Jul 17, 2026
- getStructType: array-typed struct fields missing @parameterized now throw
  a named exception instead of silently emitting invalid signature tokens
  (e.g. "dynamicarray") that hash to wrong selectors; conversely,
  @parameterized on a non-array field is rejected at the
  getTypeReferenceForParameterizedField entry, covering the signature path
  and all three decode paths.
- Pin every case conversion in main sources to Locale.ROOT (abi token
  builders, core AbiUtils switches, utils helpers): under a Turkish default
  locale 'I' lowercases to dotless 'ı', corrupting "Int256" into
  "ınt256" and silently changing selectors. Turkish-locale regression
  test added.
- encodePacked: the array gate validates actual element types in addition
  to the declared componentType — arrays declared with a supertype
  component (e.g. Type.class) could smuggle strings, structs and nested
  arrays past the gate and produce corrupt packed bytes.
- AbiTypes.getTypeAString: canonicalize bare Uint/Int/Ufixed/Fixed to their
  256-suffixed tokens, aligning with Utils.getSimpleTypeName; the instance
  path used to emit "uint[]", whose selector matches no on-chain contract
  (solc normalizes the alias before hashing).
- TrcToken now extends Uint: decodeNumeric takes the unsigned branch, so a
  word with the top bit set decodes to the positive token id instead of a
  two's-complement negative, and encoding values >= 2^255 no longer throws;
  the canonical "trcToken" signature token is unchanged.
- Deprecate Address(Uint), which stores the Uint without 160-bit
  normalization and packs 32 bytes instead of the canonical 20.
- Remove the misspelled EventEncoder.encodeWithOutPrefix /
  buildEventSignatureWithOutPrefix (new on this branch, no production
  callers, absent from web3j) before 1.0.0 freezes the API.
- FunctionEncoder: demote the two newly added abstract methods
  (encodeWithSelector, encodePackedParameters) to throwing defaults. The
  abstract+ServiceLoader subclass contract has been published since 0.9.2;
  new abstract methods would break every existing third-party encoder
  (compile error, or AbstractMethodError via the SPI). Legacy subclasses
  keep serving the original entry points; only the new selector/packed
  entry points fail, with a clear UnsupportedOperationException.
@0xbigapple
0xbigapple requested a review from 317787106 July 21, 2026 08:03
@kuny0707
kuny0707 merged commit 9530cf0 into tronprotocol:release_1.0.0 Jul 21, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants