From 1db6f2faebaccdcc39455b4a3dbae05c6416271a Mon Sep 17 00:00:00 2001 From: CJ42 Date: Thu, 25 Apr 2024 11:33:44 +0100 Subject: [PATCH] feat: add support for `bytesN` valueType from 1 to 32 --- src/lib/encoder.test.ts | 51 +++++++++++++++++++--- src/lib/encoder.ts | 96 ++++++++++++++++++++++++++++++----------- src/lib/utils.ts | 10 +++++ test/mockSchema.ts | 3 +- 4 files changed, 125 insertions(+), 35 deletions(-) diff --git a/src/lib/encoder.test.ts b/src/lib/encoder.test.ts index 8ddf60d3..55334fdb 100644 --- a/src/lib/encoder.test.ts +++ b/src/lib/encoder.test.ts @@ -145,16 +145,35 @@ describe('encoder', () => { encodedValue: '0x7765656b', // utf8-encoded characters decodedValue: '0x7765656b', }, + ]; + + oneWayEncodingTestCases.forEach((testCase) => { + it(`encodes one way \`input\` = ${testCase.input} as ${testCase.valueType}, but does not decode back as the same input`, async () => { + const encodedValue = encodeValueType( + testCase.valueType, + testCase.input, + ); + + assert.deepStrictEqual(encodedValue, testCase.encodedValue); + assert.deepStrictEqual( + decodeValueType(testCase.valueType, encodedValue), + testCase.decodedValue, + ); + }); + }); + + const leftPaddedTestCases = [ { valueType: 'bytes4', input: 1122334455, - encodedValue: '0x42e576f7', // number converted to hex + right padded + encodedValue: '0x42e576f7', // number converted to hex + left padded still decodedValue: '0x42e576f7', }, ]; - oneWayEncodingTestCases.forEach((testCase) => { - it(`encodes one way \`input\` = ${testCase.input} as ${testCase.valueType}, but does not decode back as the same input`, async () => { + // numbers encoded as `bytesN` are left padded to allow symmetric encoding / decoding + leftPaddedTestCases.forEach((testCase) => { + it(`encodes + left pad numbers \`input\` = ${testCase.input} as ${testCase.valueType} padded on the left with \`00\`s`, async () => { const encodedValue = encodeValueType( testCase.valueType, testCase.input, @@ -273,18 +292,36 @@ describe('encoder', () => { encodedValue: '0x546869732073656e74656e6365206973203332206279746573206c6f6e672021', }, + ]; + + oneWayEncodingTestCases.forEach((testCase) => { + it(`encodes one way \`input\` = ${testCase.input} as ${testCase.valueType}, but does not decode back as the same input`, async () => { + const encodedValue = encodeValueType( + testCase.valueType, + testCase.input, + ); + + assert.deepStrictEqual(encodedValue, testCase.encodedValue); + assert.deepStrictEqual( + decodeValueType(testCase.valueType, encodedValue), + testCase.decodedValue, + ); + }); + }); + + const leftPaddedTestCases = [ { valueType: 'bytes32', input: 12345, decodedValue: - '0x3039000000000000000000000000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000000000000000000000000000003039', encodedValue: - '0x3039000000000000000000000000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000000000000000000000000000003039', }, ]; - oneWayEncodingTestCases.forEach((testCase) => { - it(`encodes one way \`input\` = ${testCase.input} as ${testCase.valueType}, but does not decode back as the same input`, async () => { + leftPaddedTestCases.forEach((testCase) => { + it(`encodes + left pad \`input\` = ${testCase.input} as ${testCase.valueType} padded on the right with \`00\`s`, async () => { const encodedValue = encodeValueType( testCase.valueType, testCase.input, diff --git a/src/lib/encoder.ts b/src/lib/encoder.ts index c5d12d11..74f30891 100644 --- a/src/lib/encoder.ts +++ b/src/lib/encoder.ts @@ -57,17 +57,18 @@ import { countNumberOfBytes, isValidUintSize, countSignificantBits, + isValidBytesSize, } from './utils'; import { ERC725JSONSchemaValueType } from '../types/ERC725JSONSchema'; const abiCoder = AbiCoder; const uintNValueTypeRegex = /^uint(\d+)$/; - +``; +const bytesNValueTypeRegex = /^bytes(\d+)$/; +``; const BytesNValueContentRegex = /Bytes(\d+)/; -const ALLOWED_BYTES_SIZES = [2, 4, 8, 16, 32, 64, 128, 256]; - export const encodeDataSourceWithHash = ( verification: undefined | Verification, dataSource: string, @@ -171,23 +172,58 @@ export const decodeDataSourceWithHash = (value: string): URLDataWithHash => { }; }; +type BytesNValueTypes = + | 'bytes1' + | 'bytes2' + | 'bytes3' + | 'bytes4' + | 'bytes5' + | 'bytes6' + | 'bytes7' + | 'bytes8' + | 'bytes9' + | 'bytes10' + | 'bytes11' + | 'bytes12' + | 'bytes13' + | 'bytes14' + | 'bytes15' + | 'bytes16' + | 'bytes17' + | 'bytes18' + | 'bytes19' + | 'bytes20' + | 'bytes21' + | 'bytes22' + | 'bytes23' + | 'bytes24' + | 'bytes25' + | 'bytes26' + | 'bytes27' + | 'bytes28' + | 'bytes29' + | 'bytes30' + | 'bytes31' + | 'bytes32'; + const encodeToBytesN = ( - bytesN: 'bytes32' | 'bytes4', + bytesN: BytesNValueTypes, value: string | number, ): string => { + const numberOfBytesInType = parseInt(bytesN.split('bytes')[1], 10); + let valueToEncode: string; if (typeof value === 'string' && !isHex(value)) { // if we receive a plain string (e.g: "hey!"), convert it to utf8-hex data valueToEncode = toHex(value); } else if (typeof value === 'number') { - // if we receive a number as input, convert it to hex - valueToEncode = numberToHex(value); + // if we receive a number as input, convert it to hex, left padded + valueToEncode = padLeft(numberToHex(value), numberOfBytesInType * 2); } else { valueToEncode = value; } - const numberOfBytesInType = Number.parseInt(bytesN.split('bytes')[1], 10); const numberOfBytesInValue = countNumberOfBytes(valueToEncode); if (numberOfBytesInValue > numberOfBytesInType) { @@ -204,7 +240,7 @@ const encodeToBytesN = ( } const bytesArray = hexToBytes(abiEncodedValue); - return bytesToHex(bytesArray.slice(0, 4)); + return bytesToHex(bytesArray.slice(0, numberOfBytesInType)); }; /** @@ -441,6 +477,10 @@ const valueTypeEncodingMap = ( decode: (value: string) => any; } => { const uintNRegexMatch = type.match(uintNValueTypeRegex); + const bytesNRegexMatch = type.match(bytesNValueTypeRegex); + const bytesLength = bytesNRegexMatch + ? Number.parseInt(bytesNRegexMatch[1], 10) + : ''; const uintLength = uintNRegexMatch ? Number.parseInt(uintNRegexMatch[0].slice(4), 10) @@ -463,6 +503,13 @@ const valueTypeEncodingMap = ( return compactBytesArrayMap[type]; } + if (type === 'bytes') { + return { + encode: (value: string) => toHex(value), + decode: (value: string) => value, + }; + } + switch (type) { case 'bool': case 'boolean': @@ -552,27 +599,24 @@ const valueTypeEncodingMap = ( return toBN(value).toNumber(); }, }; - case 'bytes32': + case `bytes${bytesLength}`: return { - encode: (value: string | number) => encodeToBytesN('bytes32', value), - decode: (value: string) => abiCoder.decodeParameter('bytes32', value), - }; - case 'bytes4': - return { - encode: (value: string | number) => encodeToBytesN('bytes4', value), + encode: (value: string | number) => { + if (!isValidBytesSize(bytesLength as number)) { + throw new Error( + `Can't encode ${value} as ${type}. Invalid \`bytesN\` provided. Expected a \`N\` value for bytesN between 1 and 32.`, + ); + } + return encodeToBytesN(type as BytesNValueTypes, value); + }, decode: (value: string) => { // we need to abi-encode the value again to ensure that: - // - that data to decode does not go over 4 bytes. - // - if the data is less than 4 bytes, that it gets padded to 4 bytes long. - const reEncodedData = abiCoder.encodeParameter('bytes4', value); - return abiCoder.decodeParameter('bytes4', reEncodedData); + // - that data to decode does not go over N bytes. + // - if the data is less than N bytes, that it gets padded to N bytes long. + const reEncodedData = abiCoder.encodeParameter(type, value); + return abiCoder.decodeParameter(type, reEncodedData); }, }; - case 'bytes': - return { - encode: (value: string) => toHex(value), - decode: (value: string) => value, - }; case 'bool[]': return { encode: (value: boolean) => abiCoder.encodeParameter('bool[]', value), @@ -778,7 +822,7 @@ export const valueContentEncodingMap = ( throw new Error(`Value: ${value} is not hex.`); } - if (bytesLength && !ALLOWED_BYTES_SIZES.includes(bytesLength)) { + if (bytesLength && !isValidBytesSize(bytesLength)) { throw new Error( `Provided bytes length: ${bytesLength} for encoding valueContent: ${valueContent} is not valid.`, ); @@ -800,7 +844,7 @@ export const valueContentEncodingMap = ( return null; } - if (bytesLength && !ALLOWED_BYTES_SIZES.includes(bytesLength)) { + if (bytesLength && !isValidBytesSize(bytesLength)) { console.error( `Provided bytes length: ${bytesLength} for encoding valueContent: ${valueContent} is not valid.`, ); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index c7fb78db..e22545a2 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -816,3 +816,13 @@ export const duplicateMultiTypeERC725SchemaEntry = ( export function isValidUintSize(bitSize: number) { return bitSize >= 8 && bitSize <= 256 && bitSize % 8 === 0; } + +/* + * `bytesN` must be a valid number of bytes between 1 and 32 + * e.g: bytes1, bytes2, bytes3, bytes4, ..., bytes32 + * + * @param bytesSize the size of the fixed size bytes value + */ +export function isValidBytesSize(bytesSize: number) { + return bytesSize >= 1 && bytesSize <= 32; +} diff --git a/test/mockSchema.ts b/test/mockSchema.ts index ffd8829f..485b778e 100644 --- a/test/mockSchema.ts +++ b/test/mockSchema.ts @@ -357,8 +357,7 @@ export const mockSchema: (ERC725JSONSchema & { returnRawDataArray: abiCoder.encodeParameter('bytes[]', [ '0x0000000000000000000000000000000000000000000000000000000000000063', ]), - returnGraphData: - '0x0000000000000000000000000000000000000000000000000000000000000063', + returnGraphData: '0x63', expectedResult: 99, },