diff --git a/__tests__/contract.test.ts b/__tests__/contract.test.ts index d2c81e490..a5719f904 100644 --- a/__tests__/contract.test.ts +++ b/__tests__/contract.test.ts @@ -16,6 +16,7 @@ import { RpcError, ReceiptTx, RpcProvider, + contractLoader, } from '../src'; import { CONTRACTS, createTestProvider, getTestAccount, initializeMatcher } from './config'; @@ -244,10 +245,13 @@ describe('contract module', () => { }); test('factory deployment of new contract with constructor arguments as already compiled calldata', async () => { + const loadedContract = contractLoader('./__mocks__/cairo/cairo294'); + // Ensure casm is loaded + expect(loadedContract.casm).toBeDefined(); + const erc20 = await Contract.factory({ - contract: CONTRACTS.Erc20Oz100.sierra, - casm: CONTRACTS.Erc20Oz100.casm, - classHash: erc20ClassHash, + contract: loadedContract.sierra, + casm: loadedContract.casm!, account, constructorCalldata: erc20Constructor, }); diff --git a/__tests__/utils/contractLoader.test.ts b/__tests__/utils/contractLoader.test.ts new file mode 100644 index 000000000..8dbcd7398 --- /dev/null +++ b/__tests__/utils/contractLoader.test.ts @@ -0,0 +1,106 @@ +import path from 'path'; +import { contractLoader } from '../../src/utils/contract'; + +describe('contractLoader', () => { + const mocksBase = path.resolve(__dirname, '../../__mocks__'); + const mockDir = path.join(mocksBase, 'cairo/cairo210'); + const sierraFile = path.join(mockDir, 'cairo210.sierra.json'); + const casmFile = path.join(mockDir, 'cairo210.casm'); + + describe('Loading from directory', () => { + test('should load both sierra and casm from directory', () => { + const contract = contractLoader(mockDir); + + expect(contract).toBeDefined(); + expect(contract.casm).toBeDefined(); + expect(contract.sierra).toBeDefined(); + expect(contract.compiler).toBe('2.1.0'); + }); + }); + + describe('Loading from sierra file', () => { + test('should load sierra and find casm in same directory', () => { + const contract = contractLoader(sierraFile); + + expect(contract).toBeDefined(); + expect(contract.casm).toBeDefined(); + expect(contract.sierra).toBeDefined(); + expect(contract.compiler).toBe('2.1.0'); + }); + }); + + describe('Loading from casm file', () => { + test('should load casm and find sierra in same directory', () => { + const contract = contractLoader(casmFile); + + expect(contract).toBeDefined(); + expect(contract.casm).toBeDefined(); + expect(contract.sierra).toBeDefined(); + expect(contract.compiler).toBe('2.1.0'); + }); + }); + + describe('Error handling', () => { + test('should throw error if no sierra file found', () => { + // Try to load a casm-only file (blake2s) which should fail + const casmOnlyFile = path.join(mocksBase, 'cairo/blake2s_verification_contract.casm'); + + expect(() => contractLoader(casmOnlyFile)).toThrow('No .sierra.json file found'); + }); + }); + + describe('Sierra-only contracts', () => { + test('should return sierra without casm if no casm file exists', () => { + // Use complexInput which has only sierra file + const sierraOnlyDir = path.join(mocksBase, 'cairo/complexInput'); + const contract = contractLoader(sierraOnlyDir); + + expect(contract).toBeDefined(); + expect(contract.sierra).toBeDefined(); + expect(contract.casm).toBeUndefined(); + expect(contract.compiler).toBeUndefined(); + }); + }); + + describe('Integration with Contract class and declare', () => { + test('loaded contract should have correct structure for Contract and declare', () => { + // Load contract using contractLoader + const loadedContract = contractLoader(mockDir); + + // Verify the contract has correct structure for declare/deploy + expect(loadedContract.sierra).toBeDefined(); + expect(loadedContract.casm).toBeDefined(); + + // Verify sierra has required properties for Contract class + expect(loadedContract.sierra.abi).toBeDefined(); + expect(Array.isArray(loadedContract.sierra.abi)).toBe(true); + expect('sierra_program' in loadedContract.sierra).toBe(true); + + // Verify casm has required properties for declare + expect('bytecode' in loadedContract.casm!).toBe(true); + expect('compiler_version' in loadedContract.casm!).toBe(true); + + // Verify compiler version is extracted + expect(loadedContract.compiler).toBe('2.1.0'); + // Note: compiler_version exists on CompiledSierraCasm but not on LegacyContractClass + if (loadedContract.casm && 'compiler_version' in loadedContract.casm) { + expect(loadedContract.compiler).toBe(loadedContract.casm.compiler_version); + } + + // Verify the loaded contract structure matches what declareAndDeploy expects + // This validates that contractLoader returns properly parsed contracts with correct types + const declarePayload = { + contract: loadedContract.sierra, + casm: loadedContract.casm, + }; + + expect(declarePayload.contract).toBeDefined(); + expect(declarePayload.contract.abi).toBeDefined(); + expect(declarePayload.casm).toBeDefined(); + + // Verify ABI is properly parsed (not just a string) + expect(typeof declarePayload.contract.abi).toBe('object'); + expect(Array.isArray(declarePayload.contract.abi)).toBe(true); + }); + }); +}); diff --git a/src/utils/contract.ts b/src/utils/contract.ts index 3d5e0a4c4..4483312fd 100644 --- a/src/utils/contract.ts +++ b/src/utils/contract.ts @@ -80,3 +80,8 @@ export function contractClassResponseToLegacyCompiledContract(ccr: ContractClass const contract = ccr as LegacyContractClass; return { ...contract, program: decompressProgram(contract.program) } as LegacyCompiledContract; } + +// Re-export LoadedContract type and contractLoader function with runtime detection +// Works in both Node.js (filesystem) and browsers (File API) +export type { LoadedContract } from './contractLoader'; +export { contractLoader, isFileSystemAvailable } from './contractLoader'; diff --git a/src/utils/contractLoader.ts b/src/utils/contractLoader.ts new file mode 100644 index 000000000..9508a618c --- /dev/null +++ b/src/utils/contractLoader.ts @@ -0,0 +1,269 @@ +import { CompiledSierra, CairoAssembly } from '../types/lib/contract/index'; +import { parse } from './json'; + +export interface LoadedContract { + sierra: CompiledSierra; + casm?: CairoAssembly; + compiler?: string; +} + +/** + * Helper function to check if filesystem access is available (Node.js environment) + * @return {boolean} - Returns true if running in Node.js with fs module available + */ +export function isFileSystemAvailable(): boolean { + try { + return typeof require !== 'undefined' && typeof require.resolve === 'function'; + } catch { + return false; + } +} + +/** + * Loads a Cairo contract from filesystem (Node.js) or File object (browser). + * + * **Node.js usage:** + * Accepts a directory path or a direct path to a .sierra.json or .casm file. + * - If a directory is provided: searches for .sierra.json and .casm files + * - If a file path is provided: loads that file and attempts to find the complementary file + * + * **Browser usage:** + * Accepts a File object (from file input or drag-and-drop). + * - Returns a Promise that resolves to LoadedContract + * - Automatically detects .sierra.json and .casm files + * - Can accept a single .sierra.json or .casm file, or multiple files + * + * @param {string | File | File[]} input - Path (Node.js) or File/File[] (browser) + * @return {LoadedContract | Promise} - Contract data (sync in Node.js, async in browser) + * @throws {Error} - If no .sierra.json file is found or file reading fails + * + * @example + * ```typescript + * // Node.js: Load from directory + * const contract = contractLoader('./contracts/my_contract'); + * + * // Node.js: Load from .sierra.json file + * const contract = contractLoader('./contracts/my_contract.sierra.json'); + * + * // Browser: Load from file input + * const fileInput = document.querySelector('input[type="file"]'); + * const contract = await contractLoader(fileInput.files[0]); + * + * // Browser: Load from multiple files + * const contract = await contractLoader([sierraFile, casmFile]); + * ``` + */ +export function contractLoader(contractPath: string): LoadedContract; +export function contractLoader(file: File): Promise; +export function contractLoader(files: File[]): Promise; +export function contractLoader( + input: string | File | File[] +): LoadedContract | Promise { + // Browser path: File or File[] + if (typeof File !== 'undefined' && (input instanceof File || Array.isArray(input))) { + return loadFromFileAPI(input); + } + + // Node.js path: string + if (typeof input === 'string') { + if (!isFileSystemAvailable()) { + throw new Error( + 'contractLoader with string paths is only available in Node.js environments. ' + + 'In browsers, please use File objects from or drag-and-drop. ' + + 'Example: await contractLoader(fileInput.files[0])' + ); + } + return loadFromFileSystem(input); + } + + throw new Error( + 'Invalid input type. Expected string (Node.js path) or File/File[] (browser File API)' + ); +} + +/** + * Load contract from Node.js filesystem (synchronous) + */ +function loadFromFileSystem(contractPath: string): LoadedContract { + // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require + const fs = require('fs'); + // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require + const path = require('path'); + + // Resolve relative paths to absolute paths + const resolvedPath = path.resolve(contractPath); + + let dirPath: string; + let specifiedSierraFile: string | undefined; + let specifiedCasmFile: string | undefined; + + // Check if the path is a file or directory + const stats = fs.statSync(resolvedPath); + + if (stats.isFile()) { + // If it's a file, extract the directory and remember which file was specified + dirPath = path.dirname(resolvedPath); + const fileName = path.basename(resolvedPath); + + if ( + fileName.endsWith('.sierra.json') || + (fileName.endsWith('.json') && !fileName.endsWith('.casm')) + ) { + specifiedSierraFile = fileName; + } else if (fileName.endsWith('.casm')) { + specifiedCasmFile = fileName; + } else { + throw new Error( + `Invalid file type. Expected .json, .sierra.json, or .casm file, got: ${fileName}` + ); + } + } else if (stats.isDirectory()) { + dirPath = resolvedPath; + } else { + throw new Error(`Path is neither a file nor a directory: ${contractPath}`); + } + + // Read all files in the directory + const files = fs.readdirSync(dirPath); + + let sierraFile: string | undefined; + let casmFile: string | undefined; + + if (specifiedSierraFile) { + // User specified a .json file - look for matching .casm + sierraFile = specifiedSierraFile; + const baseName = sierraFile.replace(/\.sierra\.json$/, '').replace(/\.json$/, ''); + casmFile = files.find((f: string) => f === `${baseName}.casm`); + } else if (specifiedCasmFile) { + // User specified a .casm file - look for matching .json + casmFile = specifiedCasmFile; + const baseName = casmFile.replace(/\.casm$/, ''); + // Try .sierra.json first, then .json + sierraFile = + files.find((f: string) => f === `${baseName}.sierra.json`) || + files.find((f: string) => f === `${baseName}.json`); + } else { + // User specified a directory - find .sierra.json files and their matching .casm + const sierraFiles = files.filter( + (f: string) => f.endsWith('.sierra.json') || (f.endsWith('.json') && !f.endsWith('.casm')) + ); + + if (sierraFiles.length === 0) { + throw new Error(`No .sierra.json file found in ${dirPath}. Sierra file is required.`); + } + + if (sierraFiles.length > 1) { + throw new Error( + `Multiple .sierra.json files found in ${dirPath}: ${sierraFiles.join(', ')}. Please specify which file to use.` + ); + } + + [sierraFile] = sierraFiles; + const baseName = sierraFile!.replace(/\.sierra\.json$/, '').replace(/\.json$/, ''); + casmFile = files.find((f: string) => f === `${baseName}.casm`); + } + + // Sierra is required + if (!sierraFile) { + throw new Error(`No .sierra.json file found in ${dirPath}. Sierra file is required.`); + } + + // Load sierra file + const sierraPath = path.join(dirPath, sierraFile); + const sierraContent = parse(fs.readFileSync(sierraPath, 'utf8')); + + const result: LoadedContract = { + sierra: sierraContent, + }; + + // Load casm if available + if (casmFile) { + const casmPath = path.join(dirPath, casmFile); + const casmContent = parse(fs.readFileSync(casmPath, 'utf8')); + result.casm = casmContent; + result.compiler = casmContent.compiler_version; + } + + return result; +} + +/** + * Load contract from browser File API (asynchronous) + */ +async function loadFromFileAPI(input: File | File[]): Promise { + const files = Array.isArray(input) ? input : [input]; + + if (files.length === 0) { + throw new Error('No files provided'); + } + + // Identify sierra and casm files + const sierraFiles = files.filter( + (file) => + file.name.endsWith('.sierra.json') || + (file.name.endsWith('.json') && !file.name.endsWith('.casm')) + ); + const casmFiles = files.filter((file) => file.name.endsWith('.casm')); + + if (sierraFiles.length > 1) { + throw new Error( + `Multiple .sierra.json files provided: ${sierraFiles.map((f) => f.name).join(', ')}. Please provide only one sierra file.` + ); + } + + if (casmFiles.length > 1) { + throw new Error( + `Multiple .casm files provided: ${casmFiles.map((f) => f.name).join(', ')}. Please provide only one casm file.` + ); + } + + const sierraFile = sierraFiles[0]; + const casmFile = casmFiles[0]; + + if (!sierraFile) { + throw new Error( + 'No .sierra.json file found in provided files. Sierra file is required. ' + + `Provided files: ${files.map((f) => f.name).join(', ')}` + ); + } + + // Read sierra file + const sierraContent = parse(await readFileAsText(sierraFile)); + + const result: LoadedContract = { + sierra: sierraContent, + }; + + // Read casm file if available + if (casmFile) { + const casmContent = parse(await readFileAsText(casmFile)); + result.casm = casmContent; + result.compiler = casmContent.compiler_version; + } + + return result; +} + +/** + * Helper to read a File object as text using FileReader + */ +function readFileAsText(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = (event) => { + const content = event.target?.result; + if (typeof content === 'string') { + resolve(content); + } else { + reject(new Error(`Failed to read file ${file.name} as text`)); + } + }; + + reader.onerror = () => { + reject(new Error(`Failed to read file ${file.name}: ${reader.error?.message}`)); + }; + + reader.readAsText(file); + }); +}