From 786fe39b24032f832b6a2410a19b53b4618cd8e9 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 17 Sep 2026 19:22:01 -0600 Subject: [PATCH] feat(erc7730): compile nested arrays and NFT collections --- keepkeylib/erc7730_compiler.py | 50 ++++++++++++++++++++++++++-------- tests/test_erc7730_compiler.py | 25 +++++++++++++++++ 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/keepkeylib/erc7730_compiler.py b/keepkeylib/erc7730_compiler.py index 6fef8bdd..b62cdb70 100644 --- a/keepkeylib/erc7730_compiler.py +++ b/keepkeylib/erc7730_compiler.py @@ -638,6 +638,31 @@ def intern_path(steps): literals.append((9, _u16(len(references)) + b"".join( _u16(index) for index in references))) arguments.append((22, 2, len(literals) - 1)) + elif kind == 4: + collection = params.get("collectionPath") + if collection is None: + collection = params.get("collection") + collection = descriptor_value(collection) + if isinstance(collection, str) and collection.startswith("0x"): + literals.append((5, _hex_address(collection))) + collection_path = intern_path( + (("literal", len(literals) - 1),)) + elif isinstance(collection, str): + collection_path = intern_path(_resolve_path(root, collection)) + else: + raise ValueError("nftName requires collection or collectionPath") + arguments.append((3, 1, collection_path)) + chain = params.get("chainIdPath", params.get("chainId")) + if chain is not None: + chain = descriptor_value(chain) + if isinstance(chain, int): + literals.append((7, _unsigned_literal(chain))) + arguments.append((11, 2, len(literals) - 1)) + elif isinstance(chain, str): + arguments.append((11, 1, + intern_path(_resolve_path(root, chain)))) + else: + raise ValueError("invalid nftName chainId") elif kind == 5: encoding = params.get("encoding", "timestamp") arguments.append((9, 3, string_index[encoding])) @@ -704,21 +729,24 @@ def intern_path(steps): value_path, len(literals) - 1)) all_positions = [i for i, step in enumerate(steps) if step[0] == 2] - if len(all_positions) > 1: - raise ValueError("nested array iteration requires a field group") if all_positions: - prefix = steps[:all_positions[0]] - array_path = intern_path(prefix) - begin = len(displays) - displays.append([7, array_path, condition, 0]) + begins = [] + for position in all_positions: + array_path = intern_path(steps[:position + 1]) + begin = len(displays) + begins.append(begin) + displays.append([7, array_path, condition, 0]) displays.append([4, string_index[field["label"]], formatter_index, ABSENT]) - end = len(displays) separator = field.get("separator") - displays.append([8, begin, - string_index[separator] if separator else ABSENT, - ABSENT]) - displays[begin][3] = end + for depth, begin in enumerate(reversed(begins)): + end = len(displays) + displays.append([ + 8, begin, + (string_index[separator] + if separator and depth == len(begins) - 1 else ABSENT), + ABSENT]) + displays[begin][3] = end else: displays.append([4, string_index[field["label"]], formatter_index, condition]) diff --git a/tests/test_erc7730_compiler.py b/tests/test_erc7730_compiler.py index 1a10aadb..f108eff0 100644 --- a/tests/test_erc7730_compiler.py +++ b/tests/test_erc7730_compiler.py @@ -244,6 +244,31 @@ def test_compiles_array_iteration_separator_and_optional_visibility(): subprocess.check_call([validator, output.name]) +def test_compiles_recursive_array_iteration_frames(): + descriptor = {"display": {"formats": { + "matrix(uint256[][] values)": { + "intent": "Review matrix", + "fields": [{"path": "values.[].[]", "label": "Value", + "format": "raw", "separator": "Next row"}], + } + }}} + compiled = compile_calldata( + descriptor, "matrix(uint256[][] values)", 1, + "0x1111111111111111111111111111111111111111") + display = _sections(compiled)[7] + count = int.from_bytes(display[:2], "big") + instructions = [display[2 + i * 8:10 + i * 8] for i in range(count)] + assert [item[0] for item in instructions] == [1, 7, 7, 4, 8, 8, 10] + assert int.from_bytes(instructions[1][6:8], "big") == 5 + assert int.from_bytes(instructions[2][6:8], "big") == 4 + validator = os.environ.get("ERC7730_FIRMWARE_VALIDATOR") + if validator: + with tempfile.NamedTemporaryFile() as output: + output.write(compiled) + output.flush() + subprocess.check_call([validator, output.name]) + + def test_compiles_typed_if_not_in_and_must_match_conditions(): descriptor = {"display": {"formats": { "guard(uint256 mode,address recipient)": {