Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions __tests__/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
RpcError,
ReceiptTx,
RpcProvider,
contractLoader,
} from '../src';

import { CONTRACTS, createTestProvider, getTestAccount, initializeMatcher } from './config';
Expand Down Expand Up @@ -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,
});
Expand Down
106 changes: 106 additions & 0 deletions __tests__/utils/contractLoader.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
5 changes: 5 additions & 0 deletions src/utils/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 from the Node.js-specific module
// Note: contractLoader uses Node.js fs/path APIs and is only available in Node.js environments
export type { LoadedContract } from './contractLoaderNode';
export { contractLoader } from './contractLoaderNode';
20 changes: 20 additions & 0 deletions src/utils/contractLoaderBrowser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { CairoAssembly, CompiledSierra } from '../types/lib/contract/index';

export interface LoadedContract {
sierra: CompiledSierra;
casm?: CairoAssembly;
compiler?: string;
}

/**
* Browser stub for contractLoader.
* This function is not available in browser environments.
* Use it only in Node.js environments (server-side or build scripts).
*/
export function contractLoader(_contractPath: string): LoadedContract {
throw new Error(
'contractLoader is only available in Node.js environment. ' +
'This function requires filesystem access and cannot be used in browsers. ' +
'Load your contracts using other methods (e.g., import, fetch) in browser environments.'
);
}
136 changes: 136 additions & 0 deletions src/utils/contractLoaderNode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { CompiledSierra, CairoAssembly } from '../types/lib/contract/index';
import { parse } from './json';

export interface LoadedContract {
sierra: CompiledSierra;
casm?: CairoAssembly;
compiler?: string;
}

/**
* Loads a Cairo contract from the filesystem.
*
* Accepts either 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
*
* @param {string} contractPath - Path to a directory or a .sierra.json/.casm file
* @return {LoadedContract} - Object containing sierra (required), casm (optional), and compiler version (optional)
* @throws {Error} - If no .sierra.json file is found, multiple .sierra.json files are found, or file reading fails
* @example
* ```typescript
* // Load from directory
* const contract = contractLoader('./contracts/my_contract');
* // contract = { sierra: {...}, casm: {...}, compiler: '2.6.0' }
*
* // Load from .sierra.json file
* const contract = contractLoader('./contracts/my_contract.sierra.json');
*
* // Load from .casm file (will find matching .sierra.json)
* const contract = contractLoader('./contracts/my_contract.casm');
* ```
*/
export function contractLoader(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
// This ensures consistent path handling throughout the function
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;
// At this point sierraFile is guaranteed to be defined because we checked sierraFiles.length
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;
}
33 changes: 27 additions & 6 deletions tsup.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,30 @@
import path from 'path';
import { defineConfig } from 'tsup';

export default defineConfig({
entry: ['src/index.ts'],
sourcemap: true,
clean: true,
format: ['cjs'],
globalName: 'starknet',
export default defineConfig((options) => {
const isBrowser = options.platform === 'browser';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that the browser platform setting is only used for the iife build. I believe that most usages of starknet.js in the browser happen by bundling the esm (or cjs) build within an application. With the node specific imports being dynamic, I surmise that most users wouldn't encounter the graceful platform compatibility error but a reference error for require.

I suggest trying a different approach. Maybe something like if (typeof require === 'undefined') throw graceful error; at the start of the contractLoader function.


return {
entry: ['src/index.ts'],
sourcemap: true,
clean: true,
format: ['cjs'],
globalName: 'starknet',
esbuildPlugins: isBrowser
? [
{
name: 'replace-node-modules',
setup(build) {
// Replace contractLoaderNode with contractLoaderBrowser in browser builds
build.onResolve({ filter: /contractLoaderNode$/ }, (args) => {
const dir = path.dirname(path.resolve(args.resolveDir, args.path));
return {
path: path.join(dir, 'contractLoaderBrowser.ts'),
};
});
},
},
]
: [],
};
});