diff --git a/.eslintignore b/.eslintignore index f9e3f7991..23e705206 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,3 @@ node_modules/ dist/ -www/ +www/ \ No newline at end of file diff --git a/__tests__/utils/calldataDecode.test.ts b/__tests__/utils/calldataDecode.test.ts new file mode 100644 index 000000000..5dbdfbc90 --- /dev/null +++ b/__tests__/utils/calldataDecode.test.ts @@ -0,0 +1,111 @@ +import { DecodeConfig } from '../../src/types'; + +import { + BigNumberish, + CairoUint256, + CallData, + Calldata, + RawArgsArray, + RawArgsObject, + cairo, +} from '../../src'; + +import { compiledComplexSierra } from '../config/fixtures'; + +const { tuple } = cairo; + +describe('Cairo 1', () => { + test('should correctly compile and decompile complex data structures', async () => { + type Order2 = { + p1: BigNumberish; + p2: BigNumberish[]; + }; + + const myOrder2bis: Order2 = { + // wrong order + p2: ['abcd', '56ec'], + p1: 'smolstring', + }; + + const secondUint256: CairoUint256 = new CairoUint256(40000n); + const tempUint256 = { low: 23456n, high: 1n } as CairoUint256; + const thirdUint256: CairoUint256 = new CairoUint256(tempUint256); + const myFalseUint256: CairoUint256 = thirdUint256; // wrong order + + const myRawArgsObject: RawArgsObject = { + // wrong order + active: true, + symbol: 'NIT', + initial_supply: myFalseUint256, + recipient: '0x7e00d496e324876bbc8531f2d9a82bf154d1a04a50218ee74cdd372f75a551a', + decimals: 18, + tupoftup: tuple(tuple(34, 94), myFalseUint256), + card: myOrder2bis, + longText: 'Bug is back, for ever, here and everywhere', + array1: [100, 101, 102], + array2: [ + [200, 201], + [202, 203], + [204, 205], + ], + array3: [myOrder2bis, myOrder2bis], + array4: [myFalseUint256, myFalseUint256], + tuple1: tuple(secondUint256, myOrder2bis, [54, 55, 174], 59), + name: 'niceToken', + array5: [tuple(251, 40000), tuple(252, 40001)], + }; + + const myRawArgsArray: RawArgsArray = [ + 'niceToken', + 'NIT', + 18, + thirdUint256, + { p1: '17', p2: ['234', '467456745457', '0x56ec'] }, + '0x7e00d496e324876bbc8531f2d9a82bf154d1a04a50218ee74cdd372f75a551a', + true, + { '0': { '0': 34, '1': 94 }, '1': thirdUint256 }, + 'Bug is back, for ever, here and everywhere', + ['100', '101', '102'], + [ + [200, 201], + [202, 203], + [204, 205], + ], + [ + { p1: '17', p2: ['234', '467456745457n', '0x56ec'] }, + { p1: '17', p2: ['234', '467456745457n', '0x56ec'] }, + ], + [thirdUint256, thirdUint256], + { + '0': secondUint256, + '1': { p1: '17', p2: ['234', '467456745457n', '0x56ec'] }, + '2': [54, 55, 56], + '3': 59, + }, + [ + { '0': 251, '1': 40000 }, + { '0': 252, '1': 40001 }, + ], + ]; + + const config: DecodeConfig = { + felt: String, + 'core::felt252': String, + 'core::integer::u8': Number, + 'core::integer::u16': Number, + 'core::integer::u64': Number, + 'core::integer::u128': BigInt, + 'core::starknet::contract_address::ContractAddress': String, + longText: String, + }; + + const cd: CallData = new CallData(compiledComplexSierra.abi); + const compiledDataFromObject: Calldata = cd.compile('constructor', myRawArgsObject); + const compiledDataFromArray: Calldata = cd.compile('constructor', myRawArgsArray); + const decompiledDataFromObject = cd.decompile('constructor', compiledDataFromObject, config); + const decompiledDataFromArray = cd.decompile('constructor', compiledDataFromArray, config); + + expect(decompiledDataFromObject).toEqual(myRawArgsObject); + expect(decompiledDataFromArray).toEqual(myRawArgsObject); + }); +}); diff --git a/src/types/lib/contract/abi.ts b/src/types/lib/contract/abi.ts index 6583165dd..d65ec4de9 100644 --- a/src/types/lib/contract/abi.ts +++ b/src/types/lib/contract/abi.ts @@ -56,3 +56,8 @@ export type LegacyEvent = { data: EventEntry[]; keys: EventEntry[]; }; + +type JSCDataType = StringConstructor | NumberConstructor | BigIntConstructor | BooleanConstructor; +export type DecodeConfig = { + [typeName: string]: JSCDataType; +}; diff --git a/src/utils/cairoDataTypes/uint256.ts b/src/utils/cairoDataTypes/uint256.ts index 1c51c4c63..f2195f34a 100644 --- a/src/utils/cairoDataTypes/uint256.ts +++ b/src/utils/cairoDataTypes/uint256.ts @@ -133,4 +133,24 @@ export class CairoUint256 { toApiRequest() { return [CairoFelt(this.low), CairoFelt(this.high)]; } + + /** + * Construct CairoUint256 from calldata + * @param calldata Array of two strings representing the low and high parts of a uint256. + */ + static fromCalldata(calldata: [string, string]): CairoUint256 { + if (calldata.length !== 2) { + throw new Error( + 'Calldata must contain exactly two elements for low and high parts of uint256.' + ); + } + + // Validate each part to ensure they are within the acceptable range. + const [low, high] = calldata; + const validatedLow = CairoUint256.validateProps(low, UINT_256_LOW_MIN.toString()); + const validatedHigh = CairoUint256.validateProps(high, UINT_256_HIGH_MIN.toString()); + + // Construct a new instance based on the validated low and high parts. + return new CairoUint256(validatedLow.low, validatedHigh.high); + } } diff --git a/src/utils/calldata/cairo.ts b/src/utils/calldata/cairo.ts index 09b419703..3d38283a2 100644 --- a/src/utils/calldata/cairo.ts +++ b/src/utils/calldata/cairo.ts @@ -89,6 +89,26 @@ export const isTypeResult = (type: string) => type.startsWith('core::result::Res * @returns - Returns true if the value is a valid Uint type, otherwise false. */ export const isTypeUint = (type: string) => Object.values(Uint).includes(type as Uint); +/** + * Retrieves the Uint enum type for the given type string. + * + * @param {string} type - The type string to check against Uint types. + * @returns {(Uint | null)} - The corresponding Uint enum value or null if not found. + */ +export const getUintType = (type: string): string | undefined => { + const uintValues = Object.values(Uint); + const iterator = uintValues[Symbol.iterator](); + let next = iterator.next(); + while (!next.done) { + const { value } = next; + if (value === type) { + return value; + } + next = iterator.next(); + } + + return undefined; +}; // Legacy Export /** * Checks if the given type is `uint256`. diff --git a/src/utils/calldata/calldataDecoder.ts b/src/utils/calldata/calldataDecoder.ts new file mode 100644 index 000000000..12fcc0676 --- /dev/null +++ b/src/utils/calldata/calldataDecoder.ts @@ -0,0 +1,353 @@ +import { + AbiEntry, + AbiEnums, + AbiStructs, + BigNumberish, + ByteArray, + DecodeConfig, + CairoEnum, + ParsedStruct, + Uint256, +} from '../../types'; +import { CairoUint256 } from '../cairoDataTypes/uint256'; +import { CairoUint512 } from '../cairoDataTypes/uint512'; +import { toHex } from '../num'; +import { decodeShortString } from '../shortString'; +import { stringFromByteArray } from './byteArray'; +import { addHexPrefix, removeHexPrefix } from '../encode'; +import { + getArrayType, + isTypeArray, + isTypeBytes31, + isTypeEnum, + isTypeBool, + isLen, + isCairo1Type, + isTypeFelt, + isTypeUint, + getUintType, + isTypeByteArray, + isTypeSecp256k1Point, + isTypeOption, + isTypeResult, + isTypeContractAddress, + isTypeEthAddress, + isTypeTuple, +} from './cairo'; +import { + CairoCustomEnum, + CairoEnumRaw, + CairoOption, + CairoOptionVariant, + CairoResult, + CairoResultVariant, +} from './enum'; +import extractTupleMemberTypes from './tuple'; + +/** + * Decode base types from calldata + * @param type type of element + * @param it iterator + * @returns CairoUint256 | CairoUint512 | Boolean | string | BigNumberish + */ +function decodeBaseTypes( + type: string, + it: Iterator, + config?: DecodeConfig +): + | Boolean + | ParsedStruct + | BigNumberish + | Uint256 + | BigNumberish[] + | CairoOption + | CairoResult + | CairoEnum { + let temp; + switch (true) { + case isTypeBool(type): + temp = it.next().value; + return Boolean(BigInt(temp)); + + case isTypeUint(type): + switch (true) { + case CairoUint256.isAbiType(type): { + const low = it.next().value; + const high = it.next().value; + + const ret = new CairoUint256(low, high); + const configConstructor = config?.['core::integer::u256']; + if (configConstructor) { + return configConstructor(ret); + } + + return ret; + } + + case CairoUint512.isAbiType(type): { + const limb0 = it.next().value; + const limb1 = it.next().value; + const limb2 = it.next().value; + const limb3 = it.next().value; + + return new CairoUint512(limb0, limb1, limb2, limb3).toBigInt(); + } + + default: { + temp = it.next().value; + const configType = getUintType(type); + if (configType) { + const UintConstructor = config?.[configType]; + if (UintConstructor) { + return UintConstructor(temp); + } + return BigInt(temp); + } + } + } + + return BigInt(temp); + + case isTypeEthAddress(type): + temp = it.next().value; + return BigInt(temp); + + case isTypeContractAddress(type): { + temp = it.next().value; + temp = toHex(temp); + const configConstructor = config?.[type]; + if (configConstructor) { + return configConstructor(temp); + } + return BigInt(temp); + } + + case isTypeBytes31(type): + temp = it.next().value; + return decodeShortString(temp); + + case isTypeSecp256k1Point(type): { + const xLow = removeHexPrefix(it.next().value).padStart(32, '0'); + const xHigh = removeHexPrefix(it.next().value).padStart(32, '0'); + const yLow = removeHexPrefix(it.next().value).padStart(32, '0'); + const yHigh = removeHexPrefix(it.next().value).padStart(32, '0'); + const pubK = BigInt(addHexPrefix(xHigh + xLow + yHigh + yLow)); + + return pubK; + } + + case isTypeFelt(type): { + temp = String(it.next().value); + const configFeltConstructor = config?.['core::felt252']; + if (configFeltConstructor) { + if (configFeltConstructor === String) return decodeShortString(temp); + return configFeltConstructor(temp); + } + + // Default + return BigInt(temp); + } + + default: + temp = it.next().value; + return BigInt(temp); + } +} + +/** + * Decodes calldata based on the provided type, using an iterator over the calldata. + * @param calldataIterator Iterator over the encoded calldata strings. + * @param type The type string as defined in the ABI. + * @param structs Optional struct definitions from ABI. + * @param enums Optional enum definitions from ABI. + * @returns Decoded calldata as a JavaScript-compatible type. + */ +function decodeCalldataValue( + calldataIterator: Iterator, + element: { name: string; type: string }, + structs: AbiStructs, + enums: AbiEnums, + config?: DecodeConfig +): + | Boolean + | ParsedStruct + | Uint256 + | BigNumberish + | BigNumberish[] + | any[] + | CairoOption + | CairoResult + | CairoEnum { + if (element.type === '()') { + return {}; + } + + // type uint256 struct (c1v2) + if (CairoUint256.isAbiType(element.type)) { + const low = calldataIterator.next().value; + const high = calldataIterator.next().value; + return new CairoUint256(low, high); + } + + // type uint512 struct + if (CairoUint512.isAbiType(element.type)) { + const limb0 = calldataIterator.next().value; + const limb1 = calldataIterator.next().value; + const limb2 = calldataIterator.next().value; + const limb3 = calldataIterator.next().value; + return new CairoUint512(limb0, limb1, limb2, limb3).toBigInt(); + } + + // type C1 ByteArray struct, representing a LongString + if (isTypeByteArray(element.type)) { + const parsedBytes31Arr: BigNumberish[] = []; + const bytes31ArrLen = BigInt(calldataIterator.next().value); + while (parsedBytes31Arr.length < bytes31ArrLen) { + parsedBytes31Arr.push(toHex(calldataIterator.next().value)); + } + const pending_word = toHex(calldataIterator.next().value); + const pending_word_len = BigInt(calldataIterator.next().value); + const myByteArray: ByteArray = { + data: parsedBytes31Arr, + pending_word, + pending_word_len, + }; + return stringFromByteArray(myByteArray); + } + + // type struct + if (structs && element.type in structs && structs[element.type]) { + if (isTypeEthAddress(element.type)) { + return decodeBaseTypes(element.type, calldataIterator, config); + } + if (isTypeContractAddress(element.type)) { + return decodeBaseTypes(element.type, calldataIterator, config); + } + + return structs[element.type].members.reduce((acc, el) => { + acc[el.name] = decodeCalldataValue(calldataIterator, el, structs, enums, config); + return acc; + }, {} as any); + } + + // type Enum (only CustomEnum) + if (enums && element.type in enums && enums[element.type]) { + const variantNum: number = Number(calldataIterator.next().value); // get variant number + const rawEnum = enums[element.type].variants.reduce((acc, variant, num) => { + if (num === variantNum) { + acc[variant.name] = decodeCalldataValue( + calldataIterator, + { name: '', type: variant.type }, + structs, + enums, + config + ); + return acc; + } + acc[variant.name] = undefined; + return acc; + }, {} as CairoEnumRaw); + // Option + if (isTypeOption(element.type)) { + const content = variantNum === CairoOptionVariant.Some ? rawEnum.Some : undefined; + return new CairoOption(variantNum, content); + } + // Result + if (isTypeResult(element.type)) { + let content: Object; + if (variantNum === CairoResultVariant.Ok) { + content = rawEnum.Ok; + } else { + content = rawEnum.Err; + } + return new CairoResult(variantNum, content); + } + // Cairo custom Enum + const customEnum = new CairoCustomEnum(rawEnum); + return customEnum; + } + + // type tuple + if (isTypeTuple(element.type)) { + const memberTypes = extractTupleMemberTypes(element.type); + return memberTypes.reduce((acc, it: any, idx) => { + const name = it?.name ? it.name : idx; + const type = it?.type ? it.type : it; + const el = { name, type }; + acc[name] = decodeCalldataValue(calldataIterator, el, structs, enums, config); + return acc; + }, {} as any); + } + + // type c1 array + if (isTypeArray(element.type)) { + // eslint-disable-next-line no-case-declarations + const parsedDataArr = []; + const el: AbiEntry = { name: '', type: getArrayType(element.type) }; + const len = BigInt(calldataIterator.next().value); // get length + while (parsedDataArr.length < len) { + const val = decodeCalldataValue(calldataIterator, el, structs, enums, config); + if ( + el.type === 'core::integer::u128' || + el.type === 'core::integer::u8' || + el.type === 'core::integer::u16' + ) { + parsedDataArr.push(Number(val)); + } else { + parsedDataArr.push(val); + } + } + const configConstructor = config?.[element.name]; + if (configConstructor) { + const concatenatedString = parsedDataArr.join(''); + return concatenatedString; + } + return parsedDataArr; + } + + // base type + return decodeBaseTypes(element.type, calldataIterator, config); +} + +/** + * Decode calldata fields using provided ABI details. + * @param calldataIterator Iterator over the string array representing encoded calldata. + * @param input ABI entry for the field to decode. + * @param structs Struct definitions from ABI, if applicable. + * @param enums Enum definitions from ABI, if applicable. + * @returns Decoded field value. + */ +export default function decodeCalldataField( + calldataIterator: Iterator, + input: AbiEntry, + structs: AbiStructs, + enums: AbiEnums, + config?: DecodeConfig +): any { + const { name, type } = input; + + switch (true) { + case isLen(name): { + const temp = calldataIterator.next().value; + return BigInt(temp); + } + + case (structs && type in structs) || isTypeTuple(type): + return decodeCalldataValue(calldataIterator, input, structs, enums, config); + + case enums && isTypeEnum(type, enums): + return decodeCalldataValue(calldataIterator, input, structs, enums, config); + + case isTypeArray(type): + // C1 Array + if (isCairo1Type(type)) { + return decodeCalldataValue(calldataIterator, input, structs, enums, config); + } + break; + + default: + return decodeBaseTypes(type, calldataIterator, config); + } + + return null; +} diff --git a/src/utils/calldata/index.ts b/src/utils/calldata/index.ts index e9765c806..a5b97825b 100644 --- a/src/utils/calldata/index.ts +++ b/src/utils/calldata/index.ts @@ -7,10 +7,12 @@ import { Args, ArgsOrCalldata, Calldata, + DecodeConfig, FunctionAbi, HexCalldata, RawArgs, RawArgsArray, + RawArgsObject, Result, ValidateType, } from '../../types'; @@ -32,6 +34,7 @@ import { createAbiParser, isNoConstructorValid } from './parser'; import { AbiParserInterface } from './parser/interface'; import orderPropsByAbi from './propertyOrder'; import { parseCalldataField } from './requestParser'; +import decodeCalldataField from './calldataDecoder'; import responseParser from './responseParser'; import validateFields from './validate'; @@ -239,6 +242,35 @@ export class CallData { return callTreeArray; } + /** + * Decompile calldata into JavaScript-compatible types based on ABI definitions. + * @param method The method name as defined in the ABI. + * @param calldata Array of strings representing the encoded calldata. + * @returns A structured object representing the decoded calldata. + */ + public decompile(method: string, calldata: string[], config?: DecodeConfig): RawArgs { + const abiMethod = this.abi.find( + (entry) => entry.name === method && entry.type === 'function' + ) as FunctionAbi; + if (!abiMethod) { + throw new Error(`Method ${method} not found in ABI`); + } + + const calldataIterator = calldata.flat()[Symbol.iterator](); + const decodedArgs = abiMethod.inputs.reduce((acc, input) => { + acc[input.name] = decodeCalldataField( + calldataIterator, + input, + this.structs, + this.enums, + config + ); + return acc; + }, {} as RawArgsObject); + + return decodedArgs; + } + /** * Parse elements of the response array and structuring them into response object * @param method string - method name diff --git a/src/utils/calldata/requestParser.ts b/src/utils/calldata/requestParser.ts index 0a02c96b8..d9b693459 100644 --- a/src/utils/calldata/requestParser.ts +++ b/src/utils/calldata/requestParser.ts @@ -22,11 +22,14 @@ import { isTypeBytes31, isTypeEnum, isTypeOption, + isTypeEthAddress, + isTypeContractAddress, isTypeResult, isTypeSecp256k1Point, isTypeStruct, isTypeTuple, uint256, + isTypeByteArray, } from './cairo'; import { CairoCustomEnum, @@ -147,10 +150,11 @@ function parseCalldataValue( if (CairoUint512.isAbiType(type)) { return new CairoUint512(element as any).toApiRequest(); } - if (type === 'core::starknet::eth_address::EthAddress') - return parseBaseTypes(type, element as BigNumberish); + if (isTypeEthAddress(type)) return parseBaseTypes(type, element as BigNumberish); - if (type === 'core::byte_array::ByteArray') return parseByteArray(element as string); + if (isTypeContractAddress(type)) return parseBaseTypes(type, element as BigNumberish); + + if (isTypeByteArray(type)) return parseByteArray(element as string); const { members } = structs[type]; const subElement = element as any; @@ -297,8 +301,11 @@ export function parseCalldataField( } return parseCalldataValue(value, input.type, structs, enums); - case type === 'core::starknet::eth_address::EthAddress': + case isTypeEthAddress(type): + return parseBaseTypes(type, value); + case isTypeContractAddress(type): return parseBaseTypes(type, value); + // Struct or Tuple case isTypeStruct(type, structs) || isTypeTuple(type) || diff --git a/src/utils/calldata/responseParser.ts b/src/utils/calldata/responseParser.ts index fbf7fd433..a379aa3ac 100644 --- a/src/utils/calldata/responseParser.ts +++ b/src/utils/calldata/responseParser.ts @@ -23,7 +23,11 @@ import { isTypeArray, isTypeBool, isTypeByteArray, + isTypeBytes31, isTypeEnum, + isTypeOption, + isTypeResult, + isTypeEthAddress, isTypeSecp256k1Point, isTypeTuple, } from './cairo'; @@ -59,10 +63,10 @@ function parseBaseTypes(type: string, it: Iterator) { const limb2 = it.next().value; const limb3 = it.next().value; return new CairoUint512(limb0, limb1, limb2, limb3).toBigInt(); - case type === 'core::starknet::eth_address::EthAddress': + case isTypeEthAddress(type): temp = it.next().value; return BigInt(temp); - case type === 'core::bytes_31::bytes31': + case isTypeBytes31(type): temp = it.next().value; return decodeShortString(temp); case isTypeSecp256k1Point(type): @@ -141,7 +145,7 @@ function parseResponseValue( // type struct if (structs && element.type in structs && structs[element.type]) { - if (element.type === 'core::starknet::eth_address::EthAddress') { + if (isTypeEthAddress(element.type)) { return parseBaseTypes(element.type, responseIterator); } return structs[element.type].members.reduce((acc, el) => { @@ -167,12 +171,12 @@ function parseResponseValue( return acc; }, {} as CairoEnumRaw); // Option - if (element.type.startsWith('core::option::Option')) { + if (isTypeOption(element.type)) { const content = variantNum === CairoOptionVariant.Some ? rawEnum.Some : undefined; return new CairoOption(variantNum, content); } // Result - if (element.type.startsWith('core::result::Result')) { + if (isTypeResult(element.type)) { let content: Object; if (variantNum === CairoResultVariant.Ok) { content = rawEnum.Ok; @@ -231,11 +235,10 @@ export default function responseParser( parsedResult?: Args | ParsedStruct ): any { const { name, type } = output; - let temp; switch (true) { case isLen(name): - temp = responseIterator.next().value; + const temp = responseIterator.next().value; return BigInt(temp); case (structs && type in structs) || isTypeTuple(type):