Skip to content

Multi-dimensional arrays are broken at the level of DB creation from JSON. This affects CurveRouterNG abi #218

Description

@michwill

Summary

tools/abi.py: process_type mis-encodes multi-dimensional arrays (uint256[5][5]uint256[55]), breaking clear-signing for Curve Router NG and any nested-array call.

The DB build tool flattens multi-dimensional array types when generating ABI entries. The 4-byte selector is stored correctly, but the argument-type metadata is wrong, so affected functions fail on-device argument validation and the device falls back to displaying raw calldata (i.e. blind signing).

This currently affects the entire Curve Router NG exchange family, which is present in the shipped database but stored with a corrupt argument schema.

Affected component

tools/abi.pyprocess_type, invoked via process_functionprocess_abi, driven by tools/shell-db.py. Present on master as of this writing.

Root cause

process_type parses the type string character by character, but it collects all bracketed dimensions into a single accumulator and wraps the base type in an array exactly once:

    for c in abi_type["type"]:
        if c.isdigit():
            if type_in_array:
                type_array_size = type_array_size + c   # (1) never reset between brackets
            else:
                type_size = type_size + c
        elif (c == "["):
            type_in_array = True
        elif (c == "]"):
            type_in_array = False
            type_array = True
        ...

    if type_array:                                       # (2) one wrapper, regardless of dimension count
        arr_type = 0
        if type_array_size == "":
            arr_type = ETH_ABI_VARARRAY
        else:
            arr_type = (ETH_ABI_ARRAY | int(type_array_size))
        res = {"type": arr_type, "child": [res]}

For uint256[5][5]:

  • (1) the digits of both [5] groups are concatenated → type_array_size = "55", and
  • (2) the base type is wrapped once → ETH_ABI_ARRAY | 55.

Net result: uint256[5][5] (a 5×5 nested array, 25 words) is encoded as uint256[55] (a single 55-element array, 55 words). Dynamic nesting (T[][]) collapses to a single T[], and mixed dimensions (T[3][], T[][3]) are likewise mis-parsed.

Note the selector is unaffected — it comes from eth_utils.abi_to_signature(func) in process_function, which produces the correct canonical signature. So affected entries match on selector but carry an incorrect argument schema.

Impact

In the shipped DB (version 20260409), the Curve Router NG exchange overloads are present but corrupt. Decoding the AB records shows all three storing uint256[55] where the real signature is uint256[5][5]:

stored selector correct signature stored (corrupt) schema
371dc447 exchange(address[11],uint256[5][5],uint256,uint256) …uint256[55]…
5c9c18e2 exchange(address[11],uint256[5][5],uint256,uint256,address[5]) …uint256[55]…
c872a3c5 exchange(address[11],uint256[5][5],uint256,uint256,address[5],address) …uint256[55]…

On-device, a real Curve Router NG swap is therefore not recognized and shown as raw hex. The selector matches the stored entry, but eth_data_find_function (app/ethereum/eth_data.c) fails argument-structure validation against the corrupt schema, so eth_data_recognize returns NULL and the transaction is blind-signed.

Concrete example (mainnet), a crvUSD → USDC swap through exchange(address[11],uint256[5][5],uint256,uint256,address[5]) (selector 5c9c18e2): tx 0x7a2b64cc18f4853d0ca5b04e3cefcb0c6cfdb0024a17aa2b528ffd9f7c18431e.

This generalizes to any contract whose functions take multi-dimensional arrays; Curve Router NG is simply the most widely used case.

Reproduction

  1. Build the DB with any input containing a multi-dimensional-array function (or use the shipped 20260409 DB).
  2. Parse the resulting AB records and reconstruct the stored signature from the argument-type tree — the nested dimension is collapsed (e.g. uint256[55] instead of uint256[5][5]).
  3. On a device, sign a Curve Router NG exchange call and observe that it displays raw calldata rather than decoded arguments.

Proposed fix

Parse the dimensions into an ordered list and emit one array wrapper per dimension (innermost first; per Solidity/ABI, the rightmost bracket is the outermost array):

def process_type(abi_type):
    type_base = ""
    type_size = ""
    dims = []
    cur_dim = ""
    type_in_array = False

    for c in abi_type["type"]:
        if c.isdigit():
            if type_in_array:
                cur_dim = cur_dim + c
            else:
                type_size = type_size + c
        elif (c == "["):
            type_in_array = True
            cur_dim = ""
        elif (c == "]"):
            type_in_array = False
            dims.append(int(cur_dim) if cur_dim != "" else None)  # None => dynamic []
        else:
            type_base = type_base + c

    res = {"child": []}
    if type_base == "tuple":
        res["type"] = ETH_ABI_TUPLE
        for component in abi_type["components"]:
            res["child"] = res["child"] + [process_type(component)]
    elif type_base == "int":
        res["type"] = (ETH_ABI_INT | bitsize(type_size))
    elif type_base == "uint":
        res["type"] = (ETH_ABI_UINT | bitsize(type_size))
    elif (type_base == "fixed") or (type_base == "fixedx"):
        res["type"] = (ETH_ABI_FIXED | 32)
    elif (type_base == "ufixed") or (type_base == "ufixedx"):
        res["type"] = (ETH_ABI_UFIXED | 32)
    elif type_base == "address":
        res["type"] = (ETH_ABI_ADDRESS | 20)
    elif type_base == "bytes":
        if type_size == "":
            res["type"] = ETH_ABI_VARBYTES
        else:
            res["type"] = (ETH_ABI_BYTES | int(type_size))
    elif type_base == "string":
        res["type"] = ETH_ABI_STRING
    elif type_base == "bool":
        res["type"] = (ETH_ABI_BOOL | 1)

    # one array wrapper per dimension; innermost first
    for d in dims:
        arr_type = ETH_ABI_VARARRAY if d is None else (ETH_ABI_ARRAY | d)
        res = {"type": arr_type, "child": [res]}

    return res

What changed: type_array/type_array_size become an ordered dims list (each [ resets cur_dim, each ] appends one dimension, None for dynamic), and the single wrapper becomes a loop that applies one wrapper per dimension.

Verified against the existing serialize_argument and a decoder round-trip on the 5-arg Router NG exchange:

before (current): exchange(address[11],uint256[55],uint256,uint256,address[5])   [73 words]
after  (patched): exchange(address[11],uint256[5][5],uint256,uint256,address[5]) [43 words]

43 words matches the real calldata; 73 does not.

Please also verify the on-device decoder path

The packer fix produces a correct nested tree, but these signatures have never actually been exercised on-device (they are currently unreachable because of the corrupt schema). It would be worth an end-to-end test — recognition and rendering — for a function with static multi-dimensional arrays such as the Router NG exchange, to confirm the fix is sufficient on its own.

One spot worth checking specifically: in eth_data.c, eth_data_tuple_get_elem reads an offset word (content_off) for ETH_ABI_COMPOSITE arguments including the non-VARLEN (fixed-size) branch. Static fixed arrays are encoded inline in ABI calldata (no offset), so it's worth confirming the fixed-array head handling is correct once entries with valid nested schemas actually reach it.

Suggested regression test

A build-time round-trip check would have caught this: for each generated ABI entry, reconstruct the canonical signature from the serialized argument tree and assert that keccak(signature)[:4] equals the stored selector. Representative cases to cover: nested fixed arrays (uint256[5][5]), dynamic arrays (address[]), arrays of tuples, and mixed dimensions.

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