/* This file is part of web3.js. web3.js is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. web3.js is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with web3.js. If not, see . */ import { InvalidBytesError, InvalidNumberError } from '@theqrl/web3-errors'; import { VALID_QRL_BASE_TYPES } from './constants.js'; import { FullValidationSchema, JsonSchema, ShortValidationSchema, ValidationSchemaInput, ValidInputTypes, } from './types.js'; import { isAbiParameterSchema } from './validation/abi.js'; import { isHexStrict } from './validation/string.js'; import { Web3ValidatorError } from './errors.js'; // eslint-disable-next-line import/no-cycle import { isAddressString } from './validation/address.js'; // Format names that are not ABI types. A name must appear here as well as in `formats.ts`, // otherwise `convertEthType` rejects it as an unknown data type before the format ever runs. const extraTypes = [ 'hex', 'number', 'blockNumber', 'blockNumberOrTag', 'filter', 'bloom', 'eip712TypedData', ]; export const parseBaseType = ( type: string, ): { baseType?: T; baseTypeSize: number | undefined; arraySizes: number[]; isArray: boolean; } => { // Remove all empty spaces to avoid any parsing issue. let strippedType = type.replace(/ /, ''); let baseTypeSize: number | undefined; let isArray = false; let arraySizes: number[] = []; if (type.includes('[')) { // Extract the array type strippedType = strippedType.slice(0, strippedType.indexOf('[')); // Extract array indexes arraySizes = [...type.matchAll(/(?:\[(\d*)\])/g)] .map(match => parseInt(match[1], 10)) .map(size => (Number.isNaN(size) ? -1 : size)); isArray = arraySizes.length > 0; } if (VALID_QRL_BASE_TYPES.includes(strippedType)) { return { baseType: strippedType as unknown as T, isArray, baseTypeSize, arraySizes }; } if (strippedType.startsWith('int')) { baseTypeSize = parseInt(strippedType.substring(3), 10); strippedType = 'int'; } else if (strippedType.startsWith('uint')) { baseTypeSize = parseInt(strippedType.substring(4), 10); strippedType = 'uint'; } else if (strippedType.startsWith('bytes')) { baseTypeSize = parseInt(strippedType.substring(5), 10); strippedType = 'bytes'; } else { return { baseType: undefined, isArray: false, baseTypeSize: undefined, arraySizes }; } return { baseType: strippedType as unknown as T, isArray, baseTypeSize, arraySizes }; }; const assertSupportedSize = ( type: string, baseType: string | undefined, baseTypeSize: number | undefined, ): void => { if (baseTypeSize === undefined || Number.isNaN(baseTypeSize)) return; if ( (baseType === 'int' || baseType === 'uint') && (baseTypeSize <= 0 || baseTypeSize > 256 || baseTypeSize % 8 !== 0) ) { throw new Web3ValidatorError([ { keyword: 'eth', message: `Eth data type "${type}" is not valid: unsupported ${baseType} size`, params: { eth: type }, instancePath: '', schemaPath: '', }, ]); } if (baseType === 'bytes' && (baseTypeSize <= 0 || baseTypeSize > 32)) { throw new Web3ValidatorError([ { keyword: 'eth', message: `Eth data type "${type}" is not valid: unsupported bytes size`, params: { eth: type }, instancePath: '', schemaPath: '', }, ]); } }; const convertEthType = ( type: string, parentSchema: JsonSchema = {}, ): { format?: string; required?: boolean } => { const typePropertyPresent = Object.keys(parentSchema).includes('type'); if (typePropertyPresent) { throw new Web3ValidatorError([ { keyword: 'eth', message: 'Either "eth" or "type" can be presented in schema', params: { eth: type }, instancePath: '', schemaPath: '', }, ]); } const { baseType, baseTypeSize } = parseBaseType(type); assertSupportedSize(type, baseType as string | undefined, baseTypeSize); if (!baseType && !extraTypes.includes(type)) { throw new Web3ValidatorError([ { keyword: 'eth', message: `Eth data type "${type}" is not valid`, params: { eth: type }, instancePath: '', schemaPath: '', }, ]); } if (baseType) { if (baseType === 'tuple') { throw new Error('"tuple" type is not implemented directly.'); } return { format: `${baseType}${baseTypeSize ?? ''}`, required: true }; } if (type) { return { format: type, required: true }; } return {}; }; export const abiSchemaToJsonSchema = ( abis: ShortValidationSchema | FullValidationSchema, level = '/0', ) => { const schema: JsonSchema = { type: 'array', items: [], maxItems: abis.length, minItems: abis.length, }; for (const [index, abi] of abis.entries()) { // eslint-disable-next-line no-nested-ternary let abiType!: string; let abiName!: string; let abiComponents: ShortValidationSchema | FullValidationSchema | undefined = []; // If it's a complete Abi Parameter // e.g. {name: 'a', type: 'uint'} if (isAbiParameterSchema(abi)) { abiType = abi.type; abiName = abi.name || `${level}/${index}`; abiComponents = abi.components as FullValidationSchema; // If its short form string value e.g. ['uint'] } else if (typeof abi === 'string') { abiType = abi; abiName = `${level}/${index}`; // If it's provided in short form of tuple e.g. [['uint', 'string']] } else if (Array.isArray(abi)) { // If its custom tuple e.g. ['tuple[2]', ['uint', 'string']] if ( abi[0] && typeof abi[0] === 'string' && abi[0].startsWith('tuple') && !Array.isArray(abi[0]) && abi[1] && Array.isArray(abi[1]) ) { // eslint-disable-next-line prefer-destructuring abiType = abi[0]; abiName = `${level}/${index}`; abiComponents = abi[1] as ReadonlyArray; } else { abiType = 'tuple'; abiName = `${level}/${index}`; abiComponents = abi; } } const { baseType, isArray, arraySizes } = parseBaseType(abiType); // Schema for the element type, i.e. the ABI type with every array // dimension stripped off (`uint` for `uint[2][3]`, the tuple for `tuple[3][5]`). let elementSchema: JsonSchema; if (baseType === 'tuple') { elementSchema = abiSchemaToJsonSchema(abiComponents, abiName); // For a bare tuple the `$id` belongs on the tuple itself. For an array of // tuples it belongs on the outermost array, and is set after wrapping below. if (!isArray) { elementSchema.$id = abiName; } } else if (isArray) { elementSchema = convertEthType(String(baseType)); } else { elementSchema = { $id: abiName, ...convertEthType(abiType) }; } if (!isArray) { (schema.items as JsonSchema[]).push(elementSchema); continue; } // `arraySizes` is in declaration order, left to right. Solidity reverses the // usual notation: `T[k][n]` is an array of `n` elements of type `T[k]`, so the // *rightmost* size is the outermost dimension and `arraySizes[0]` is the // innermost one. Wrap the element schema from the inside out so each declared // size lands on the nesting level it actually constrains. let arraySchema: JsonSchema = elementSchema; for (const arraySize of arraySizes) { arraySchema = { type: 'array', items: arraySchema }; // A dynamic dimension (`T[]`, parsed as -1) carries no size constraint. if (arraySize >= 0) { arraySchema.minItems = arraySize; arraySchema.maxItems = arraySize; } } arraySchema.$id = abiName; (schema.items as JsonSchema[]).push(arraySchema); } return schema; }; export const qrlAbiToJsonSchema = (abis: ValidationSchemaInput) => abiSchemaToJsonSchema(abis); export const fetchArrayElement = (data: Array, level: number): unknown => { if (level === 1) { return data; } return fetchArrayElement(data[0] as Array, level - 1); }; export const transformJsonDataToAbiFormat = ( abis: FullValidationSchema, data: ReadonlyArray | Record, transformedData?: Array, ): Array => { const newData: Array = []; for (const [index, abi] of abis.entries()) { // eslint-disable-next-line no-nested-ternary let abiType!: string; let abiName!: string; let abiComponents: ShortValidationSchema | FullValidationSchema | undefined = []; // If it's a complete Abi Parameter // e.g. {name: 'a', type: 'uint'} if (isAbiParameterSchema(abi)) { abiType = abi.type; abiName = abi.name; abiComponents = abi.components as FullValidationSchema; // If its short form string value e.g. ['uint'] } else if (typeof abi === 'string') { abiType = abi; // If it's provided in short form of tuple e.g. [['uint', 'string']] } else if (Array.isArray(abi)) { // If its custom tuple e.g. ['tuple[2]', ['uint', 'string']] if (abi[1] && Array.isArray(abi[1])) { abiType = abi[0] as string; abiComponents = abi[1] as ReadonlyArray; } else { abiType = 'tuple'; abiComponents = abi; } } const { baseType, isArray, arraySizes } = parseBaseType(abiType); const dataItem = Array.isArray(data) ? (data as Array)[index] : (data as Record)[abiName]; if (baseType === 'tuple' && !isArray) { newData.push( transformJsonDataToAbiFormat( abiComponents as FullValidationSchema, dataItem as Array, transformedData, ), ); } else if (baseType === 'tuple' && isArray) { const tupleData = []; for (const tupleItem of dataItem as Array) { // Nested array if (arraySizes.length > 1) { const nestedItems = fetchArrayElement( tupleItem as Array, arraySizes.length - 1, ); const nestedData = []; for (const nestedItem of nestedItems as Array) { nestedData.push( transformJsonDataToAbiFormat( abiComponents as FullValidationSchema, nestedItem as Array, transformedData, ), ); } tupleData.push(nestedData); } else { tupleData.push( transformJsonDataToAbiFormat( abiComponents as FullValidationSchema, tupleItem as Array, transformedData, ), ); } } newData.push(tupleData); } else { newData.push(dataItem); } } // Have to reassign before pushing to transformedData // eslint-disable-next-line no-param-reassign transformedData = transformedData ?? []; transformedData.push(...newData); return transformedData; }; /** * Code points to int */ export const codePointToInt = (codePoint: number): number => { if (codePoint >= 48 && codePoint <= 57) { /* ['0'..'9'] -> [0..9] */ return codePoint - 48; } if (codePoint >= 65 && codePoint <= 70) { /* ['A'..'F'] -> [10..15] */ return codePoint - 55; } if (codePoint >= 97 && codePoint <= 102) { /* ['a'..'f'] -> [10..15] */ return codePoint - 87; } throw new Error(`Invalid code point: ${codePoint}`); }; /** * Converts value to it's number representation */ export const hexToNumber = (value: string): bigint | number => { if (!isHexStrict(value)) { throw new Error('Invalid hex string'); } const [negative, hexValue] = value.startsWith('-') ? [true, value.slice(1)] : [false, value]; const num = BigInt(hexValue); if (num > Number.MAX_SAFE_INTEGER) { return negative ? -num : num; } if (num < Number.MIN_SAFE_INTEGER) { return num; } return negative ? -1 * Number(num) : Number(num); }; /** * Converts value to it's hex representation */ export const addressToHex = (value: string): string => { if (!isAddressString(value)) { throw new Error('Invalid address string'); } return value.toLowerCase().replace(/^q/i, '0x'); }; /** * Converts value to it's hex representation */ export const numberToHex = (value: ValidInputTypes): string => { if ((typeof value === 'number' || typeof value === 'bigint') && value < 0) { return `-0x${value.toString(16).slice(1)}`; } if ((typeof value === 'number' || typeof value === 'bigint') && value >= 0) { return `0x${value.toString(16)}`; } if (typeof value === 'string' && isHexStrict(value)) { const [negative, hex] = value.startsWith('-') ? [true, value.slice(1)] : [false, value]; const hexValue = hex.split(/^(-)?0(x|X)/).slice(-1)[0]; return `${negative ? '-' : ''}0x${hexValue.replace(/^0+/, '').toLowerCase()}`; } if (typeof value === 'string' && !isHexStrict(value)) { return numberToHex(BigInt(value)); } throw new InvalidNumberError(value); }; /** * Adds a padding on the left of a string, if value is a integer or bigInt will be converted to a hex string. */ export const padLeft = (value: ValidInputTypes, characterAmount: number, sign = '0'): string => { if (typeof value === 'string' && !isHexStrict(value)) { return value.padStart(characterAmount, sign); } const hex = typeof value === 'string' && isHexStrict(value) ? value : numberToHex(value); const [prefix, hexValue] = hex.startsWith('-') ? ['-0x', hex.slice(3)] : ['0x', hex.slice(2)]; return `${prefix}${hexValue.padStart(characterAmount, sign)}`; }; export function uint8ArrayToHexString(uint8Array: Uint8Array): string { let hexString = '0x'; for (const e of uint8Array) { const hex = e.toString(16); hexString += hex.length === 1 ? `0${hex}` : hex; } return hexString; } export function hexToUint8Array(hex: string): Uint8Array { let value; if (hex.toLowerCase().startsWith('0x')) { value = hex.slice(2); } else { value = hex; } if (value.length % 2 !== 0) { throw new InvalidBytesError(`hex string has odd length: ${hex}`); } if (!/^[0-9a-f]*$/i.test(value)) { throw new InvalidBytesError(`Invalid hex string: ${hex}`); } const bytes = new Uint8Array(Math.ceil(value.length / 2)); for (let i = 0; i < bytes.length; i += 1) { const byte = parseInt(value.substring(i * 2, i * 2 + 2), 16); bytes[i] = byte; } return bytes; }