diff --git a/crypto/src/main/java/org/web3j/crypto/StructuredDataEncoder.java b/crypto/src/main/java/org/web3j/crypto/StructuredDataEncoder.java index 143fcfdb1..88dfa6127 100644 --- a/crypto/src/main/java/org/web3j/crypto/StructuredDataEncoder.java +++ b/crypto/src/main/java/org/web3j/crypto/StructuredDataEncoder.java @@ -15,7 +15,6 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationTargetException; import java.math.BigInteger; import java.util.ArrayList; import java.util.Collections; @@ -102,7 +101,7 @@ public Set getDependencies(String primaryType) { String baseDeclarationTypeName = arrayTypePattern.matcher(declarationFieldTypeName).find() ? declarationFieldTypeName.substring( - 0, declarationFieldTypeName.indexOf('[')) + 0, declarationFieldTypeName.indexOf('[')) : declarationFieldTypeName; if (!types.containsKey(baseDeclarationTypeName)) { // Don't expand on non-user defined types @@ -307,16 +306,21 @@ public byte[] encodeData(String primaryType, HashMap data) List encTypes = new ArrayList<>(); List encValues = new ArrayList<>(); + List dynamicData = new ArrayList<>(); // Store dynamic data // Add typehash encTypes.add("bytes32"); encValues.add(typeHash(primaryType)); + + // Add field contents for (StructuredData.Entry field : types.get(primaryType)) { Object value = data.get(field.getName()); - if (value == null) continue; + if (value == null) { + continue; + } if (field.getType().equals("string")) { encTypes.add("bytes32"); @@ -335,44 +339,70 @@ public byte[] encodeData(String primaryType, HashMap data) encTypes.add(field.getType()); encValues.add(Numeric.hexStringToByteArray((String) value)); } else if (arrayTypePattern.matcher(field.getType()).find()) { + // Calculate header size (static part) + int headSize = 32; + // Track total size of dynamic data + int dynamicDataSize = 0; String baseTypeName = field.getType().substring(0, field.getType().indexOf('[')); List arrayItems = getArrayItems(field, value); - ByteArrayOutputStream concatenatedArrayEncodingBuffer = new ByteArrayOutputStream(); - - for (Object arrayItem : arrayItems) { - byte[] arrayItemEncoding; - if (types.containsKey(baseTypeName)) { - arrayItemEncoding = - sha3( - encodeData( - baseTypeName, - (HashMap) - arrayItem)); // need to hash each user type - // before adding - } else { - arrayItemEncoding = - convertToEncodedItem( - baseTypeName, - arrayItem); // add raw item, packed to 32 bytes + + if (baseTypeName.startsWith("uint") + || baseTypeName.startsWith("int") + || baseTypeName.equals("address") + || baseTypeName.equals("bool")) { + // Handle dynamic array + encTypes.add(baseTypeName); // Use base type instead of array type + // Add offset position, considering actual size of all previous dynamic data + encValues.add(BigInteger.valueOf(headSize + dynamicDataSize)); + + // Prepare dynamic data + ByteArrayOutputStream dynamicBuffer = new ByteArrayOutputStream(); + // Write array length + byte[] lengthBytes = + Numeric.toBytesPadded(BigInteger.valueOf(arrayItems.size()), 32); + dynamicBuffer.write(lengthBytes, 0, lengthBytes.length); + + // Write array elements + for (Object arrayItem : arrayItems) { + BigInteger itemValue = convertToBigInt(arrayItem); + byte[] itemBytes = Numeric.toBytesPadded(itemValue, 32); + dynamicBuffer.write(itemBytes, 0, itemBytes.length); } - concatenatedArrayEncodingBuffer.write( - arrayItemEncoding, 0, arrayItemEncoding.length); + byte[] dynamicBytes = dynamicBuffer.toByteArray(); + dynamicData.add(dynamicBytes); + // Update total size of dynamic data + dynamicDataSize += dynamicBytes.length; + } else { + // Handle other types of arrays + ByteArrayOutputStream concatenatedArrayEncodingBuffer = + new ByteArrayOutputStream(); + for (Object arrayItem : arrayItems) { + byte[] arrayItemEncoding; + if (types.containsKey(baseTypeName)) { + arrayItemEncoding = + sha3( + encodeData( + baseTypeName, + (HashMap) arrayItem)); + } else { + arrayItemEncoding = convertToEncodedItem(baseTypeName, arrayItem); + } + concatenatedArrayEncodingBuffer.write( + arrayItemEncoding, 0, arrayItemEncoding.length); + } + byte[] concatenatedArrayEncodings = + concatenatedArrayEncodingBuffer.toByteArray(); + byte[] hashedValue = sha3(concatenatedArrayEncodings); + encTypes.add("bytes32"); + encValues.add(hashedValue); } - - byte[] concatenatedArrayEncodings = concatenatedArrayEncodingBuffer.toByteArray(); - byte[] hashedValue = sha3(concatenatedArrayEncodings); - encTypes.add("bytes32"); - encValues.add(hashedValue); } else if (field.getType().startsWith("uint") || field.getType().startsWith("int")) { encTypes.add(field.getType()); - // convert to BigInteger for ABI constructor compatibility try { encValues.add(convertToBigInt(value)); } catch (NumberFormatException | NullPointerException e) { - encValues.add( - value); // value null or failed to convert, fallback to add string as - // before + encValues.add(value); } } else { encTypes.add(field.getType()); @@ -380,43 +410,52 @@ public byte[] encodeData(String primaryType, HashMap data) } } + // Write all data ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + // Write header (static data and offsets) for (int i = 0; i < encTypes.size(); i++) { - Class typeClazz = (Class) AbiTypes.getType(encTypes.get(i)); + String type = encTypes.get(i); + Object value = encValues.get(i); - boolean atleastOneConstructorExistsForGivenParametersType = false; - // Using the Reflection API to get the types of the parameters - Constructor[] constructors = typeClazz.getConstructors(); - for (Constructor constructor : constructors) { - // Check which constructor matches - try { - Class[] parameterTypes = constructor.getParameterTypes(); - byte[] temp = - Numeric.hexStringToByteArray( - TypeEncoder.encode( - typeClazz - .getDeclaredConstructor(parameterTypes) - .newInstance(encValues.get(i)))); - baos.write(temp, 0, temp.length); - atleastOneConstructorExistsForGivenParametersType = true; - break; - } catch (IllegalArgumentException - | NoSuchMethodException - | InstantiationException - | IllegalAccessException - | InvocationTargetException ignored) { + if (type.equals("bytes32")) { + if (value instanceof byte[]) { + baos.write((byte[]) value, 0, ((byte[]) value).length); + } else { + throw new RuntimeException("Expected byte[] for bytes32 type"); + } + } else { + Class typeClazz = (Class) AbiTypes.getType(type); + Constructor[] constructors = typeClazz.getConstructors(); + boolean encoded = false; + + for (Constructor constructor : constructors) { + try { + Class[] parameterTypes = constructor.getParameterTypes(); + byte[] temp = + Numeric.hexStringToByteArray( + TypeEncoder.encode( + typeClazz + .getDeclaredConstructor(parameterTypes) + .newInstance(value))); + baos.write(temp, 0, temp.length); + encoded = true; + break; + } catch (Exception ignored) { + } } - } - if (!atleastOneConstructorExistsForGivenParametersType) { - throw new RuntimeException( - String.format( - "Received an invalid argument for which no constructor" - + " exists for the ABI Class %s", - typeClazz.getSimpleName())); + if (!encoded) { + throw new RuntimeException("Failed to encode parameter"); + } } } + // Write dynamic data + for (byte[] dynamicBytes : dynamicData) { + baos.write(dynamicBytes, 0, dynamicBytes.length); + } + return baos.toByteArray(); } diff --git a/crypto/src/test/java/org/web3j/crypto/StructuredDataEncoderTest.java b/crypto/src/test/java/org/web3j/crypto/StructuredDataEncoderTest.java new file mode 100644 index 000000000..aa1ad94f1 --- /dev/null +++ b/crypto/src/test/java/org/web3j/crypto/StructuredDataEncoderTest.java @@ -0,0 +1,227 @@ +/* + * Copyright 2025 Web3 Labs Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package org.web3j.crypto; + +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.web3j.utils.Numeric; + +import static org.junit.jupiter.api.Assertions.*; + +public class StructuredDataEncoderTest { + + + /** + * This test compares the output of encodeData method against the expected ABI encoded output + * from a Solidity contract for the same data structure. + * + * Equivalent Solidity code for reference: + * ``` + * // SPDX-License-Identifier: MIT + * pragma solidity ^0.8.17; + * + * contract StructEncodeTest { + * struct ClaimRequest { + * address to; + * uint256[] tokenIds; + * uint256[] amounts; + * uint128 validityStartTimestamp; + * uint128 validityEndTimestamp; + * uint256 salt; + * } + * + * bytes32 constant CLAIM_REQUEST_TYPEHASH = keccak256( + * "ClaimRequest(address to,uint256[] tokenIds,uint256[] amounts,uint128 validityStartTimestamp,uint128 validityEndTimestamp,uint256 salt)" + * ); + * + * function hashClaimRequest(ClaimRequest calldata req) public pure returns (bytes32) { + * bytes32 structHash = keccak256( + * abi.encode( + * CLAIM_REQUEST_TYPEHASH, + * req.to, + * keccak256(abi.encodePacked(req.tokenIds)), + * keccak256(abi.encodePacked(req.amounts)), + * req.validityStartTimestamp, + * req.validityEndTimestamp, + * req.salt + * ) + * ); + * return structHash; + * } + * + * function encodeClaimRequest( + * address to, + * uint256[] calldata tokenIds, + * uint256[] calldata amounts, + * uint128 validityStartTimestamp, + * uint128 validityEndTimestamp, + * uint256 salt + * ) public pure returns (bytes memory) { + * return abi.encode( + * CLAIM_REQUEST_TYPEHASH, + * to, + * keccak256(abi.encodePacked(tokenIds)), + * keccak256(abi.encodePacked(amounts)), + * validityStartTimestamp, + * validityEndTimestamp, + * salt + * ); + * } + * } + * ``` + */ + @Test + public void testDynamicArrayEncoding() throws Exception { + // Create test parameters + String address = "0xe483dea6aa7d3831173379d81e5c08874f1042e7"; + List tokenIds = Arrays.asList(BigInteger.valueOf(0), BigInteger.valueOf(1)); + List amounts = Arrays.asList(BigInteger.valueOf(0), BigInteger.valueOf(0)); + BigInteger validityStartTimestamp = BigInteger.valueOf(1742919454); + BigInteger validityEndTimestamp = BigInteger.valueOf(1743005854); + BigInteger salt = BigInteger.valueOf(82); + + // Construct the JSON message + String jsonMessage = String.format(""" + { + "domain": { + "chainId": "84532", + "name": "Test", + "verifyingContract": "%s", + "version": "1.0.0" + }, + "message": { + "to": "%s", + "tokenIds": [%d, %d], + "amounts": [%d, %d], + "validityStartTimestamp": %d, + "validityEndTimestamp": %d, + "salt": "%d" + }, + "primaryType": "ClaimRequest", + "types": { + "ClaimRequest": [ + { "name": "to", "type": "address" }, + { "name": "tokenIds", "type": "uint256[]" }, + { "name": "amounts", "type": "uint256[]" }, + { "name": "validityStartTimestamp", "type": "uint128" }, + { "name": "validityEndTimestamp", "type": "uint128" }, + { "name": "salt", "type": "uint256" } + ], + "EIP712Domain": [ + { "name": "name", "type": "string" }, + { "name": "version", "type": "string" }, + { "name": "chainId", "type": "uint256" }, + { "name": "verifyingContract", "type": "address" } + ] + } + } + """, + address, address, + tokenIds.get(0), tokenIds.get(1), + amounts.get(0), amounts.get(1), + validityStartTimestamp, validityEndTimestamp, salt); + + // Create encoder and encode data + StructuredDataEncoder encoder = new StructuredDataEncoder(jsonMessage); + byte[] encoded = encoder.encodeData( + encoder.jsonMessageObject.getPrimaryType(), + (HashMap) encoder.jsonMessageObject.getMessage()); + + String hexEncoded = Numeric.toHexString(encoded); + System.out.println("Encoded data: " + hexEncoded); + + // The encoded data structure: + // [typehash(32)][to address(32)][tokenIds offset(32)][amounts offset(32)][validityStartTimestamp(32)][validityEndTimestamp(32)][salt(32)] + // [tokenIds length(32)][tokenIds data...] + // [amounts length(32)][amounts data...] + + // Verify total length - sum of all components + // 7 fields of a header = 224 bytes + // Two dynamic arrays with: + // - tokenIds: 32 bytes (length) + 2 elements * 32 bytes = 96 bytes + // - amounts: 32 bytes (length) + 2 elements * 32 bytes = 96 bytes + // Total expected length: 224 + 96 + 96 = 416 bytes + assertEquals(416, encoded.length); + + // Extract individual components from the header (first 224 bytes) + byte[] typeHash = Arrays.copyOfRange(encoded, 0, 32); + byte[] addressBytes = Arrays.copyOfRange(encoded, 32, 64); + byte[] tokenIdsOffsetBytes = Arrays.copyOfRange(encoded, 64, 96); + byte[] amountsOffsetBytes = Arrays.copyOfRange(encoded, 96, 128); + byte[] validityStartBytes = Arrays.copyOfRange(encoded, 128, 160); + byte[] validityEndBytes = Arrays.copyOfRange(encoded, 160, 192); + byte[] saltBytes = Arrays.copyOfRange(encoded, 192, 224); + + // Verify the typehash + String expectedTypehash = "0x7902270f3978ac872a876a0dae841dd76a2ca6b251714a39f68b06e66fcd5855"; + assertEquals(expectedTypehash, Numeric.toHexString(typeHash)); + + // Verify address encoding + String expectedAddress = Numeric.toHexStringWithPrefixZeroPadded( + Numeric.toBigInt(address), 64); + assertEquals(expectedAddress, Numeric.toHexString(addressBytes)); + + // Verify offsets - this is the key part of the fix + // In our implementation, these contain the real headSize + dynamicDataSize, + // not just a static reference to field positions + BigInteger tokenIdsOffset = Numeric.toBigInt(tokenIdsOffsetBytes); + assertEquals(BigInteger.valueOf(32), tokenIdsOffset); // First dynamic array offset (points to position after the 7-field header) + + BigInteger amountsOffset = Numeric.toBigInt(amountsOffsetBytes); + assertEquals(BigInteger.valueOf(32), amountsOffset); // Second dynamic array offset + + // With an incorrect implementation that doesn't handle offsets properly: + // - These would be static values like hardcoded indices + // - Or they would be calculated from field position instead of dynamic content size + // In Solidity ABI encoding, offsets point to positions relative to the start of their own data section + + // Verify timestamp and salt encoding + String expectedValidityStart = Numeric.toHexStringWithPrefixZeroPadded( + validityStartTimestamp, 64); + assertEquals(expectedValidityStart, Numeric.toHexString(validityStartBytes)); + + String expectedValidityEnd = Numeric.toHexStringWithPrefixZeroPadded( + validityEndTimestamp, 64); + assertEquals(expectedValidityEnd, Numeric.toHexString(validityEndBytes)); + + String expectedSalt = Numeric.toHexStringWithPrefixZeroPadded(salt, 64); + assertEquals(expectedSalt, Numeric.toHexString(saltBytes)); + + // Verify tokenIds array data + byte[] tokenIdsLengthBytes = Arrays.copyOfRange(encoded, 224, 256); + assertEquals(BigInteger.valueOf(2), Numeric.toBigInt(tokenIdsLengthBytes)); // 2 elements + + byte[] tokenId1Bytes = Arrays.copyOfRange(encoded, 256, 288); + assertEquals(BigInteger.valueOf(0), Numeric.toBigInt(tokenId1Bytes)); + + byte[] tokenId2Bytes = Arrays.copyOfRange(encoded, 288, 320); + assertEquals(BigInteger.valueOf(1), Numeric.toBigInt(tokenId2Bytes)); + + // Verify amounts array data + byte[] amountsLengthBytes = Arrays.copyOfRange(encoded, 320, 352); + assertEquals(BigInteger.valueOf(2), Numeric.toBigInt(amountsLengthBytes)); // 2 elements + + byte[] amount1Bytes = Arrays.copyOfRange(encoded, 352, 384); + assertEquals(BigInteger.valueOf(0), Numeric.toBigInt(amount1Bytes)); + + byte[] amount2Bytes = Arrays.copyOfRange(encoded, 384, 416); + assertEquals(BigInteger.valueOf(0), Numeric.toBigInt(amount2Bytes)); + } + +}