@layr-labs/hourglass-performer
Version:
TypeScript SDK for building Hourglass AVS performers
773 lines (641 loc) ⢠20.9 kB
text/typescript
// CLI tool for scaffolding new Hourglass performers with TypeChain integration
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
import * as readline from 'readline';
interface ProjectConfig {
name: string;
description: string;
useTypeChain: boolean;
contractsPath?: string;
port: number;
author: string;
}
/**
* CLI for creating new Hourglass performers
*/
class CreatePerformerCLI {
private rl: readline.Interface;
constructor() {
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
}
/**
* Main CLI entry point
*/
async run(args: string[]): Promise<void> {
console.log('šļø Hourglass Performer Scaffolding Tool');
console.log('ā'.repeat(50));
try {
const config = await this.collectProjectConfig(args);
const projectPath = await this.createProject(config);
await this.installDependencies(projectPath);
if (config.useTypeChain && config.contractsPath) {
await this.setupTypeChain(projectPath, config.contractsPath);
}
await this.createExampleFiles(projectPath, config);
this.showSuccessMessage(projectPath, config);
} catch (error) {
console.error('ā Error creating performer:', error);
process.exit(1);
} finally {
this.rl.close();
}
}
/**
* Collect project configuration from user
*/
private async collectProjectConfig(args: string[]): Promise<ProjectConfig> {
const config: ProjectConfig = {
name: '',
description: '',
useTypeChain: false,
port: 8080,
author: 'AVS Developer',
};
// Check for project name in arguments
if (args.length > 0) {
config.name = args[0];
} else {
config.name = await this.ask('Project name: ');
}
// Ensure name is not empty
if (!config.name.trim()) {
throw new Error('Project name is required');
}
// Validate project name
if (!config.name || !/^[a-zA-Z0-9-_]+$/.test(config.name)) {
throw new Error('Invalid project name. Use alphanumeric characters, hyphens, and underscores only.');
}
config.description = await this.ask(`Description (optional): `) || `${config.name} AVS performer`;
config.author = await this.ask('Author: ') || config.author;
const portInput = await this.ask('Port (8080): ');
if (portInput) {
const port = parseInt(portInput, 10);
if (isNaN(port) || port < 1 || port > 65535) {
throw new Error('Invalid port number');
}
config.port = port;
}
const useTypeChainInput = await this.ask('Use TypeChain for Solidity contract integration? (y/N): ');
config.useTypeChain = useTypeChainInput.toLowerCase() === 'y' || useTypeChainInput.toLowerCase() === 'yes';
if (config.useTypeChain) {
config.contractsPath = await this.ask('Path to contract ABIs (./contracts): ') || './contracts';
}
return config;
}
/**
* Create project directory and basic structure
*/
private async createProject(config: ProjectConfig): Promise<string> {
const projectPath = path.resolve(process.cwd(), config.name);
if (fs.existsSync(projectPath)) {
throw new Error(`Directory ${config.name} already exists`);
}
console.log(`\nš Creating project structure in ${projectPath}`);
// Create directory structure
fs.mkdirSync(projectPath, { recursive: true });
fs.mkdirSync(path.join(projectPath, 'src'), { recursive: true });
fs.mkdirSync(path.join(projectPath, 'src', 'workers'), { recursive: true });
fs.mkdirSync(path.join(projectPath, 'src', 'types'), { recursive: true });
if (config.useTypeChain && config.contractsPath) {
fs.mkdirSync(path.join(projectPath, config.contractsPath), { recursive: true });
fs.mkdirSync(path.join(projectPath, 'typechain-types'), { recursive: true });
}
// Create package.json
const packageJson = this.generatePackageJson(config);
fs.writeFileSync(
path.join(projectPath, 'package.json'),
JSON.stringify(packageJson, null, 2)
);
// Create TypeScript config
const tsConfig = this.generateTsConfig(config);
fs.writeFileSync(
path.join(projectPath, 'tsconfig.json'),
JSON.stringify(tsConfig, null, 2)
);
// Create .gitignore
const gitignore = this.generateGitignore(config);
fs.writeFileSync(path.join(projectPath, '.gitignore'), gitignore);
// Create README
const readme = this.generateReadme(config);
fs.writeFileSync(path.join(projectPath, 'README.md'), readme);
return projectPath;
}
/**
* Install npm dependencies
*/
private async installDependencies(projectPath: string): Promise<void> {
console.log('\nš¦ Installing dependencies...');
try {
execSync('npm install', {
cwd: projectPath,
stdio: 'inherit'
});
} catch (error) {
throw new Error('Failed to install dependencies');
}
}
/**
* Set up TypeChain configuration
*/
private async setupTypeChain(projectPath: string, contractsPath: string): Promise<void> {
console.log('\nāļø Setting up TypeChain...');
// Create typechain config
const typechainConfig = {
files: [`${contractsPath}/**/*.json`],
target: 'ethers-v6',
outDir: 'typechain-types',
};
fs.writeFileSync(
path.join(projectPath, 'typechain.config.json'),
JSON.stringify(typechainConfig, null, 2)
);
// Create sample ABI if contracts directory is empty
const fullContractsPath = path.join(projectPath, contractsPath);
if (fs.readdirSync(fullContractsPath).length === 0) {
const sampleAbi = this.generateSampleAbi();
fs.writeFileSync(
path.join(fullContractsPath, 'SampleContract.json'),
JSON.stringify(sampleAbi, null, 2)
);
}
}
/**
* Create example files based on configuration
*/
private async createExampleFiles(projectPath: string, config: ProjectConfig): Promise<void> {
console.log('\nš Creating example files...');
// Create main performer file
const mainPerformer = config.useTypeChain
? this.generateSolidityPerformer(config)
: this.generateBasicPerformer(config);
fs.writeFileSync(
path.join(projectPath, 'src', 'performer.ts'),
mainPerformer
);
// Create Docker files
const dockerfile = this.generateDockerfile(config);
fs.writeFileSync(path.join(projectPath, 'Dockerfile'), dockerfile);
const dockerCompose = this.generateDockerCompose(config);
fs.writeFileSync(path.join(projectPath, 'docker-compose.yml'), dockerCompose);
// Create example worker
if (config.useTypeChain) {
const solidityWorker = this.generateSolidityWorkerExample(config);
fs.writeFileSync(
path.join(projectPath, 'src', 'workers', 'sampleWorker.ts'),
solidityWorker
);
} else {
const basicWorker = this.generateBasicWorkerExample(config);
fs.writeFileSync(
path.join(projectPath, 'src', 'workers', 'sampleWorker.ts'),
basicWorker
);
}
}
/**
* Generate package.json content
*/
private generatePackageJson(config: ProjectConfig): any {
const basePackage: any = {
name: config.name,
version: '1.0.0',
description: config.description,
main: 'dist/performer.js',
scripts: {
build: 'tsc',
'build:watch': 'tsc --watch',
dev: 'ts-node-dev --respawn --transpile-only src/performer.ts',
start: 'node dist/performer.js',
'docker:build': `docker build -t ${config.name} .`,
'docker:run': `docker run -p ${config.port}:${config.port} ${config.name}`,
},
keywords: ['hourglass', 'avs', 'performer', 'eigenlayer'],
author: config.author,
license: 'MIT',
dependencies: {
'@hourglass/performer': '^0.1.0',
},
devDependencies: {
'@types/node': '^20.10.5',
typescript: '^5.3.3',
'ts-node-dev': '^2.0.0',
},
};
if (config.useTypeChain) {
basePackage.scripts['typechain'] = 'typechain --target ethers-v6 --out-dir typechain-types \'contracts/**/*.json\'';
basePackage.scripts['prebuild'] = 'npm run typechain';
basePackage.dependencies['ethers'] = '^6.8.0';
basePackage.devDependencies['typechain'] = '^8.3.0';
basePackage.devDependencies['@typechain/ethers-v6'] = '^0.5.0';
}
return basePackage;
}
/**
* Generate TypeScript configuration
*/
private generateTsConfig(config: ProjectConfig): any {
return {
compilerOptions: {
target: 'ES2020',
module: 'commonjs',
lib: ['ES2020'],
outDir: './dist',
rootDir: './src',
strict: true,
esModuleInterop: true,
skipLibCheck: true,
forceConsistentCasingInFileNames: true,
declaration: true,
declarationMap: true,
sourceMap: true,
resolveJsonModule: true,
typeRoots: ['./node_modules/@types', './typechain-types'],
},
include: ['src/**/*'],
exclude: ['node_modules', 'dist'],
};
}
/**
* Generate .gitignore content
*/
private generateGitignore(config: ProjectConfig): string {
let gitignore = `# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build output
dist/
*.tsbuildinfo
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs/
*.log
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
`;
if (config.useTypeChain) {
gitignore += `
# TypeChain generated files
typechain-types/
`;
}
return gitignore;
}
/**
* Generate README content
*/
private generateReadme(config: ProjectConfig): string {
return `# ${config.name}
${config.description}
## Quick Start
\`\`\`bash
# Install dependencies
npm install
# Build the project
npm run build
# Run in development mode
npm run dev
# Run in production
npm start
\`\`\`
## Docker
\`\`\`bash
# Build Docker image
npm run docker:build
# Run container
npm run docker:run
\`\`\`
${config.useTypeChain ? `## TypeChain Integration
This project uses TypeChain for type-safe Solidity contract integration.
\`\`\`bash
# Generate TypeChain types from contract ABIs
npm run typechain
# The types will be generated in ./typechain-types/
\`\`\`
Add your contract ABIs to the \`${config.contractsPath}\` directory and run \`npm run typechain\` to generate TypeScript types.
` : ''}## Architecture
- **Performer**: Main gRPC server handling task execution
- **Workers**: Task processing logic${config.useTypeChain ? ' with Solidity contract integration' : ''}
- **Health & Monitoring**: Built-in health checks and metrics collection
## Development
The performer server will automatically restart when you make changes to the source code in development mode.
## Production Deployment
1. Build the project: \`npm run build\`
2. Start the server: \`npm start\`
3. The server will listen on port ${config.port}
## Configuration
Configure your performer by modifying the server configuration in \`src/performer.ts\`.
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Submit a pull request
## License
MIT
`;
}
/**
* Generate sample ABI for TypeChain
*/
private generateSampleAbi(): any {
return {
abi: [
{
inputs: [
{ name: 'taskId', type: 'bytes32' },
{ name: 'data', type: 'bytes' },
{ name: 'user', type: 'address' },
],
name: 'processTask',
outputs: [
{ name: 'result', type: 'bytes' },
{ name: 'success', type: 'bool' },
],
stateMutability: 'nonpayable',
type: 'function',
},
{
inputs: [
{ name: 'value', type: 'uint256' },
],
name: 'square',
outputs: [
{ name: 'result', type: 'uint256' },
],
stateMutability: 'pure',
type: 'function',
},
],
bytecode: '0x608060405234801561001057600080fd5b50...',
};
}
/**
* Generate basic performer implementation
*/
private generateBasicPerformer(config: ProjectConfig): string {
return `import { BaseWorker } from '@hourglass/performer';
/**
* ${config.name} performer
*/
class ${this.toPascalCase(config.name)}Performer extends BaseWorker {
async handleSimpleTask(input: any) {
// TODO: Implement your AVS logic here
// Example: Process a number and return its square
const result = typeof input === 'number' ? input * input : 42;
return result;
}
}
// One-line server startup
new ${this.toPascalCase(config.name)}Performer().start(${config.port});
`;
}
/**
* Generate Solidity performer implementation
*/
private generateSolidityPerformer(config: ProjectConfig): string {
return `import { SolidityWorker } from '@hourglass/performer';
// TODO: Import your contract types from typechain-types
// import type { MyContract } from './typechain-types';
/**
* ${config.name} performer with TypeChain integration
*/
class ${this.toPascalCase(config.name)}Performer extends SolidityWorker<any, 'processTask'> {
async handleSolidityTask(params: { taskId: string; data: Uint8Array; user: string }) {
// TODO: Implement your AVS logic here
// params are automatically typed based on your contract ABI
const { taskId, data, user } = params;
// Example: Process the data and return result
const processedData = new TextEncoder().encode(
\`Processed: \${new TextDecoder().decode(data)}\`
);
return {
result: processedData,
success: true,
};
}
}
// One-line server startup
new ${this.toPascalCase(config.name)}Performer().start(${config.port});
`;
}
/**
* Generate basic worker example
*/
private generateBasicWorkerExample(config: ProjectConfig): string {
return `import { BaseWorker } from '@hourglass/performer';
import { TaskRequest, TaskResponse } from '@hourglass/performer';
export class SampleWorker extends BaseWorker {
async handleTask(task: TaskRequest): Promise<TaskResponse> {
try {
// Parse the task payload
const input = this.parsePayload(task.payload);
// Process the task - example: square a number
const result = typeof input === 'number' ? input * input : 42;
// Return the response
return this.createResponse(task.taskId, result);
} catch (error) {
throw new Error(\`Task processing failed: \${error instanceof Error ? error.message : 'Unknown error'}\`);
}
}
async validateTask(task: TaskRequest): Promise<void> {
await super.validateTask(task);
// Add custom validation logic here
if (!task.payload || task.payload.length === 0) {
throw new Error('Task payload is required');
}
}
}
`;
}
/**
* Generate Solidity worker example
*/
private generateSolidityWorkerExample(config: ProjectConfig): string {
return `import { SolidityWorker } from '@hourglass/performer';
import { SampleContract } from '../typechain-types/SampleContract';
// Import the contract ABI
const contractAbi = require('../../contracts/SampleContract.json').abi;
export class SampleSolidityWorker extends SolidityWorker<SampleContract, 'processTask'> {
constructor() {
super({
abi: contractAbi,
functionName: 'processTask',
autoDetectPayload: true,
strictMode: false,
});
}
async handleSolidityTask(
params: { taskId: string; data: Uint8Array; user: string }
): Promise<{ result: Uint8Array; success: boolean }> {
try {
// Access typed parameters from the contract
const { taskId, data, user } = params;
console.log(\`Processing task \${taskId} for user \${user}\`);
// Simulate some processing
const processedData = new TextEncoder().encode(
\`Processed: \${new TextDecoder().decode(data)}\`
);
return {
result: processedData,
success: true,
};
} catch (error) {
return {
result: new TextEncoder().encode(\`Error: \${error instanceof Error ? error.message : 'Unknown error'}\`),
success: false,
};
}
}
}
// Alternative: Simple worker using utility function
export class SimpleSolidityWorker extends SolidityWorker<SampleContract, 'square'> {
constructor() {
super({
abi: contractAbi,
functionName: 'square',
autoDetectPayload: true,
});
}
async handleSolidityTask(params: { value: bigint }): Promise<{ result: bigint }> {
const { value } = params;
return {
result: value * value,
};
}
}
`;
}
/**
* Generate Dockerfile
*/
private generateDockerfile(config: ProjectConfig): string {
return `FROM node:18-alpine
# Install build dependencies
RUN apk add --no-cache python3 make g++
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \\
adduser -S performer -u 1001
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy built application
COPY dist/ ./dist/
# Create required directories
RUN mkdir -p /app/logs /app/data && \\
chown -R performer:nodejs /app
# Switch to non-root user
USER performer
# Expose port
EXPOSE ${config.port}
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \\
CMD node -e "const http = require('http'); \\
const options = { hostname: 'localhost', port: ${config.port}, path: '/health', timeout: 5000 }; \\
const req = http.request(options, (res) => { \\
process.exit(res.statusCode === 200 ? 0 : 1); \\
}); \\
req.on('error', () => process.exit(1)); \\
req.end();"
# Run the application
CMD ["node", "dist/performer.js"]
`;
}
/**
* Generate docker-compose.yml
*/
private generateDockerCompose(config: ProjectConfig): string {
return `version: '3.8'
services:
${config.name}:
build: .
ports:
- "${config.port}:${config.port}"
environment:
- NODE_ENV=production
- PORT=${config.port}
- LOG_LEVEL=info
- MAX_CONCURRENT_TASKS=20
volumes:
- ./data:/app/data
- ./logs:/app/logs${config.useTypeChain ? '\n - ./contracts:/app/contracts' : ''}
restart: unless-stopped
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "${config.port}"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
`;
}
/**
* Show success message with next steps
*/
private showSuccessMessage(projectPath: string, config: ProjectConfig): void {
console.log('\nš Project created successfully!');
console.log('ā'.repeat(50));
console.log(`š Location: ${projectPath}`);
console.log(`š Port: ${config.port}`);
console.log(`š TypeChain: ${config.useTypeChain ? 'Enabled' : 'Disabled'}`);
console.log('\nš Next steps:');
console.log(` cd ${config.name}`);
if (config.useTypeChain) {
console.log(' # Add your contract ABIs to ./contracts/');
console.log(' npm run typechain');
}
console.log(' npm run dev');
console.log('\nš Available commands:');
console.log(' npm run build - Build for production');
console.log(' npm run dev - Start development server');
console.log(' npm start - Start production server');
console.log(' npm run docker:build - Build Docker image');
console.log(' npm run docker:run - Run Docker container');
console.log(' npm run docker:dev - Start with Docker Compose');
if (config.useTypeChain) {
console.log(' npm run typechain - Generate TypeChain types');
}
console.log('\nš Resources:');
console.log(' ⢠Documentation: https://docs.hourglass.io');
console.log(' ⢠Examples: https://github.com/Layr-Labs/hourglass-examples');
console.log(' ⢠Support: https://discord.gg/hourglass');
}
/**
* Prompt user for input
*/
private ask(question: string): Promise<string> {
return new Promise((resolve) => {
this.rl.question(question, resolve);
});
}
/**
* Convert kebab-case to PascalCase
*/
private toPascalCase(str: string): string {
return str.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join('');
}
}
// CLI entry point
if (require.main === module) {
const cli = new CreatePerformerCLI();
const args = process.argv.slice(2);
cli.run(args).catch(console.error);
}
export default CreatePerformerCLI;