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
118 changes: 117 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Catapult addresses the challenge of managing complex contract deployment scenari
- **🔍 Validation & Dry Run**: Validate configurations and preview deployment plans without execution
- **📊 Event System**: Rich event system for monitoring deployment progress and debugging
- **🧾 Multi-platform Verification**: Verify on Etherscan v2 and Sourcify (tries all configured platforms by default)
- **🔌 Plugin Architecture**: Extend Catapult with custom actions via plugins

## Installation

Expand Down Expand Up @@ -73,6 +74,7 @@ A Catapult project follows this structure:
```
my-deployment-project/
├── networks.yaml # Network configurations
├── catapult.config.yaml # Catapult configuration
├── jobs/ # Deployment job definitions
│ ├── core-contracts.yaml
│ ├── factory-setup.yaml
Expand Down Expand Up @@ -115,7 +117,29 @@ The `supports` field is optional and specifies which verification platforms are
- `etherscan_v2`: Etherscan v2 verification API (supports Ethereum, Polygon, Arbitrum, BSC, etc.)
- `sourcify`: Sourcify verification (no API key required)

If `supports` is omitted, all built-in platforms are allowed for that network. Etherscan requires an API key to be considered “configured”; Sourcify requires no configuration. The `gasLimit` field is optional and specifies a fixed gas limit to use for all transactions on this network. If not specified, the system will use ethers.js default gas estimation.
If `supports` is omitted, all built-in platforms are allowed for that network. Etherscan requires an API key to be considered "configured"; Sourcify requires no configuration. The `gasLimit` field is optional and specifies a fixed gas limit to use for all transactions on this network. If not specified, the system will use ethers.js default gas estimation.

### Catapult Configuration

Create a `catapult.config.yaml` (or `.json`, `.js`, `.ts`, `.yml`) file in your project root to configure plugins and extend Catapult's functionality:

```yaml
plugins:
- "@0xsequence/catapult-create4"
- "./local-plugin"
```

The configuration file supports multiple formats:
- **YAML**: `catapult.config.yaml` or `catapult.config.yml`
- **JSON**: `catapult.config.json`
- **JavaScript**: `catapult.config.js`
- **TypeScript**: `catapult.config.ts`

The `plugins` field is an array of plugin identifiers. Plugins can be:
- **npm packages**: Installed via `npm install` and referenced by package name (e.g., `@horizon/catapult-create4`)
- **Local paths**: Relative paths (e.g., `./plugins/my-plugin`) or absolute paths

If the configuration file is omitted, Catapult will run without any plugins. You can override the config file path using the `--config` flag when running commands.

### Constants

Expand Down Expand Up @@ -391,6 +415,7 @@ Common options (run):
- `--rpc-url <url>`: Run against a single custom RPC; chain ID is auto-detected (no networks.yaml required)
- `-k, --private-key <key>`: EOA private key (or set `PRIVATE_KEY`)
- `--etherscan-api-key <key>`: Etherscan API key (or set `ETHERSCAN_API_KEY`)
- `--config <path>`: Override plugin configuration file path (defaults to `catapult.config.{js|ts|json|yml|yaml}`)
- `--fail-early`: Stop as soon as any job fails
- `--ignore-verify-errors`: Convert verification errors to warnings and show complete report at end (instead of exiting with error code)
- `--no-post-check-conditions`: Skip post-execution evaluation of skip conditions
Expand Down Expand Up @@ -851,6 +876,97 @@ Output layout and selection:
- `output: false` to exclude outputs for that action
- `output: { key1: true, key2: true }` to include only specific keys from that action (e.g., `txHash`, `address`)

## Plugins

Catapult supports a plugin architecture that allows you to extend the framework with custom functionality. Plugins can register custom action handlers that integrate seamlessly with the deployment system.

### Plugin Configuration

Create a `catapult.config.json` (or `.js`, `.ts`, `.yml`, `.yaml`) file in your project root:

```json
{
"plugins": [
"@horizon/catapult-create4",
"./local-plugin"
]
}
```

Plugins can be:
- **npm packages**: Installed via `npm install` and referenced by package name
- **Local paths**: Relative paths (e.g., `./plugins/my-plugin`) or absolute paths
- **Multiple formats**: JSON, YAML, JavaScript, or TypeScript config files

### Using Plugin Actions

Once a plugin is loaded, you can use its custom actions in your YAML files:

```yaml
actions:
- name: "my-plugin-action"
type: "my-plugin/action" # Action type registered by the plugin
arguments:
param1: "value1"
param2: "{{some.output}}"
output: true
```

### Plugin Development

Plugins are Node.js modules that export a `CatapultPlugin` object:

```typescript
import { CatapultPlugin, PluginActionHandler } from '@0xsequence/catapult'

const handler: PluginActionHandler = {
type: 'my-plugin/action',
async execute(action, context, resolver, eventEmitter, hasCustomOutput, scope) {
// Resolve arguments
const param1 = await resolver.resolve(action.arguments.param1, context, scope)

// Perform custom logic
const result = await doSomething(param1)

// Emit events for visibility
eventEmitter.emitEvent({
type: 'plugin_action_completed',
level: 'info',
data: { result }
})

// Store outputs (use action.name prefix to avoid conflicts)
if (action.name && !hasCustomOutput) {
context.setOutput(`${action.name}.result`, result)
}
}
}

const plugin: CatapultPlugin = {
name: 'my-plugin',
version: '1.0.0',
actions: [handler]
}

export default plugin
```

**Key Points:**
- Use namespaced action types (e.g., `plugin-name/action-name`) to avoid conflicts
- Plugins receive full access to `ExecutionContext`, `ValueResolver`, and `DeploymentEventEmitter`
- Outputs should use the `${action.name}.${key}` pattern
- The `hasCustomOutput` flag indicates if custom outputs are specified in YAML
- Plugins can emit events for visibility and debugging

### Plugin Priority

Plugin actions are checked **before** template lookup, meaning:
1. Primitive actions (built-in)
2. **Plugin actions** (custom)
3. Templates (YAML-defined)

This allows plugins to override or extend template functionality.

## Environment Variables

- `PRIVATE_KEY`: Signer private key (alternative to `--private-key`)
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"version": "1.3.11",
"description": "Ethereum contract deployment CLI tool",
"main": "dist/index.js",
"types": "dist/lib/index.d.ts",
"bin": {
"catapult": "dist/index.js"
},
Expand Down
6 changes: 6 additions & 0 deletions src/commands/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ export const noStdOption = (cmd: Command): Command =>
export const verbosityOption = (cmd: Command): Command =>
cmd.option('-v, --verbose', 'Enable verbose logging (use -vv or -vvv for more detail)', (_, previous) => (previous || 0) + 1, 0)

/**
* Adds the --config option to a command.
*/
export const configOption = (cmd: Command): Command =>
cmd.option('--config <path>', 'Path to plugin configuration file (catapult.config.{js|ts|json|yml})')

/**
* Loads the project using the ProjectLoader and emits corresponding events.
*/
Expand Down
4 changes: 3 additions & 1 deletion src/commands/dry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import chalk from 'chalk'
import { loadProject } from './common'
import { loadNetworks } from '../lib/network-loader'
import { DependencyGraph } from '../lib/core/graph'
import { projectOption, noStdOption, verbosityOption } from './common'
import { projectOption, noStdOption, verbosityOption, configOption } from './common'
import { validateContractReferences, extractUsedContractReferences } from '../lib/validation/contract-references'
import { setVerbosity } from '../index'
import { resolveSelectedChainIds } from '../lib/network-selection'
Expand All @@ -13,6 +13,7 @@ interface DryRunOptions {
project: string
std: boolean
network?: string
config?: string
verbose: number
}

Expand Down Expand Up @@ -53,6 +54,7 @@ export function makeDryRunCommand(): Command {
.option('-n, --network <selectors>', 'Comma-separated network selectors (by chain ID or name).')

projectOption(dryRun)
configOption(dryRun)
noStdOption(dryRun)
verbosityOption(dryRun)

Expand Down
7 changes: 5 additions & 2 deletions src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { loadNetworks } from '../lib/network-loader'
import { detectNetworkFromRpc, isValidRpcUrl } from '../lib/network-utils'
import { deploymentEvents } from '../lib/events'
import { Network } from '../lib/types'
import { projectOption, dotenvOption, noStdOption, verbosityOption, loadDotenv } from './common'
import { projectOption, dotenvOption, noStdOption, verbosityOption, configOption, loadDotenv } from './common'
import { resolveSelectedChainIds } from '../lib/network-selection'
import { setVerbosity } from '../index'

Expand All @@ -14,6 +14,7 @@ interface RunOptions {
network?: string
rpcUrl?: string
dotenv?: string
config?: string
std: boolean
etherscanApiKey?: string
verbose: number
Expand Down Expand Up @@ -41,6 +42,7 @@ export function makeRunCommand(): Command {

projectOption(run)
dotenvOption(run)
configOption(run)
noStdOption(run)
verbosityOption(run)

Expand Down Expand Up @@ -116,7 +118,8 @@ export function makeRunCommand(): Command {
},
flatOutput: options.flatOutput === true,
runDeprecated: (options as { runDeprecated?: boolean }).runDeprecated === true,
ignoreVerifyErrors: options.ignoreVerifyErrors === true
ignoreVerifyErrors: options.ignoreVerifyErrors === true,
configPath: options.config,
} as DeployerOptions

const deployer = new Deployer(deployerOptions)
Expand Down
1 change: 0 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
#!/usr/bin/env node

import { program } from 'commander'
import chalk from 'chalk'
import { setupCommands } from './cli'
import packageJson from '../package.json'

Expand Down
41 changes: 20 additions & 21 deletions src/lib/core/__tests__/engine.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@ const TEST_VALUES = {
SMALL_AMOUNT: '1000000000000000000' // 1 ETH for testing
} as const

const TEST_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' // First anvil account

describe('ExecutionEngine', () => {
let engine: ExecutionEngine
let context: ExecutionContext
Expand All @@ -54,16 +52,28 @@ describe('ExecutionEngine', () => {
mockNetwork = { name: 'testnet', chainId: 999, rpcUrl }

// Try to connect to the node, fail immediately if not available
const provider = new ethers.JsonRpcProvider(rpcUrl)
await provider.getNetwork()
anvilProvider = new ethers.JsonRpcProvider(rpcUrl)
await anvilProvider.getNetwork()
})

afterAll(async () => {
if (anvilProvider) {
await anvilProvider.destroy()
}
})

const randomWallet = async (provider: ethers.JsonRpcProvider) => {
const wallet = ethers.Wallet.createRandom(provider)
// Give ETH and allow sending txs
await provider.send('anvil_setBalance', [wallet.address, ethers.parseEther('100').toString()])
await provider.send('anvil_impersonateAccount', [wallet.address])
return wallet
}

beforeEach(async () => {
const rpcUrl = process.env.RPC_URL || 'http://127.0.0.1:8545'
anvilProvider = new ethers.JsonRpcProvider(rpcUrl)

mockRegistry = new ContractRepository()
context = new ExecutionContext(mockNetwork, TEST_PRIVATE_KEY, mockRegistry)
const wallet = await randomWallet(anvilProvider)
context = new ExecutionContext(mockNetwork, wallet.privateKey, mockRegistry)

// Initialize templates map
templates = new Map()
Expand All @@ -74,17 +84,6 @@ describe('ExecutionEngine', () => {
})

afterEach(async () => {
// Clean up providers to prevent hanging connections
if (anvilProvider) {
try {
if (anvilProvider.destroy) {
await anvilProvider.destroy()
}
} catch (error) {
// Ignore cleanup errors
}
}

if (context) {
try {
await context.dispose()
Expand Down Expand Up @@ -977,7 +976,7 @@ describe('ExecutionEngine', () => {
describe('send-signed-transaction', () => {
it('should broadcast a signed transaction', async () => {
// Create a signed transaction using the same private key
const wallet = new ethers.Wallet(TEST_PRIVATE_KEY, anvilProvider)
const wallet = await randomWallet(anvilProvider)

const tx = await wallet.populateTransaction({
to: TEST_ADDRESSES.RECIPIENT_1,
Expand All @@ -1002,7 +1001,7 @@ describe('ExecutionEngine', () => {
})

it('should resolve transaction from context', async () => {
const wallet = new ethers.Wallet(TEST_PRIVATE_KEY, anvilProvider)
const wallet = await randomWallet(anvilProvider)

const tx = await wallet.populateTransaction({
to: TEST_ADDRESSES.RECIPIENT_1,
Expand Down
33 changes: 30 additions & 3 deletions src/lib/core/__tests__/resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,36 @@ describe('ValueResolver', () => {
let context: ExecutionContext
let mockNetwork: Network
let mockRegistry: ContractRepository
let testProvider: ethers.JsonRpcProvider | undefined

beforeAll(async () => {
// Allow configuring RPC URL via environment variable for CI
const rpcUrl = process.env.RPC_URL || 'http://127.0.0.1:8545'
// Try to connect to the node, fail immediately if not available
testProvider = new ethers.JsonRpcProvider(rpcUrl)
try {
await testProvider.getNetwork()
} catch (error) {
// If connection fails, that's okay - tests will handle it
if (testProvider.destroy) {
await testProvider.destroy()
}
testProvider = undefined
}
})

afterAll(async () => {
// Clean up test provider
if (testProvider) {
try {
if (testProvider.destroy) {
await testProvider.destroy()
}
} catch (error) {
// Ignore cleanup errors
}
}
})

beforeEach(async () => {
resolver = new ValueResolver()
Expand All @@ -26,9 +56,6 @@ describe('ValueResolver', () => {
// A dummy private key is fine as these tests don't send transactions
const mockPrivateKey = '0x0000000000000000000000000000000000000000000000000000000000000001'
context = new ExecutionContext(mockNetwork, mockPrivateKey, mockRegistry)

// Try to connect to the node, fail immediately if not available
await (context.provider as ethers.JsonRpcProvider).getNetwork()
})

afterEach(async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/core/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export class ExecutionContext {
public async dispose(): Promise<void> {
try {
// Destroy the provider to close any open connections
if ((this.provider as any).destroy) {
if (this.provider && typeof (this.provider as any).destroy === 'function') {
await (this.provider as any).destroy()
}
} catch (error) {
Expand Down
Loading