Catapult is a powerful Ethereum contract deployment and management framework designed to simplify the orchestration of complex contract deployments across multiple blockchain networks. Built with TypeScript and Node.js, it provides a declarative YAML-based approach to defining deployment jobs, templates, and dependencies.
Catapult addresses the challenge of managing complex contract deployment scenarios where multiple contracts need to be deployed in a specific order, with dependencies between them, across multiple blockchain networks. Instead of writing custom deployment scripts for each scenario, you define your deployment logic declaratively using YAML files.
- 🔄 Declarative Deployment Jobs: Define complex deployment workflows using YAML configuration files
- 📋 Template System: Create reusable deployment templates that can be shared across projects
- 🔗 Dependency Management: Automatic resolution of deployment dependencies and execution ordering
- 🌐 Multi-Network Support: Deploy to multiple blockchain networks simultaneously
- ⚡ Built-in Actions: Comprehensive set of built-in actions for common deployment tasks
- 🧮 Value Resolvers: Powerful system for computing values, encoding data, and performing calculations
- ✅ Skip Conditions: Smart conditional logic to avoid redundant deployments
- 🔍 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)
Available on npm as @0xsequence/catapult.
Global install (provides the catapult CLI on your PATH):
npm install -g @0xsequence/catapult
# or
yarn global add @0xsequence/catapult
# or
pnpm add -g @0xsequence/catapultProject-local install (use via npx or package.json scripts):
npm install -D @0xsequence/catapult
# or
yarn add -D @0xsequence/catapult
# or
pnpm add -D @0xsequence/catapult
# then run
npx catapult --helpgit clone <repository-url>
cd catapult
npm install
npm run build
npm linknpm install -g @0xsequence/catapultA Catapult project follows this structure:
my-deployment-project/
├── networks.yaml # Network configurations
├── jobs/ # Deployment job definitions
│ ├── core-contracts.yaml
│ ├── factory-setup.yaml
│ └── token-deployment.yaml
├── templates/ # Custom template definitions
│ ├── erc20-factory.yaml
│ └── proxy-deployment.yaml
├── artifacts/ # Contract build artifacts
│ ├── MyContract.json
│ └── Factory.json
└── output/ # Generated deployment results
Create a networks.yaml file in your project root to define target networks:
- name: "Ethereum Mainnet"
chainId: 1
rpcUrl: "https://mainnet.infura.io/v3/YOUR_PROJECT_ID"
- name: "Arbitrum One"
chainId: 42161
rpcUrl: "https://arb1.arbitrum.io/rpc"
- name: "Polygon"
chainId: 137
rpcUrl: "https://polygon-rpc.com"
supports: ["etherscan_v2"] # Optional: verification platforms supported
gasLimit: 500000 # Optional: gas limit for all transactions on this network
testnet: true # Optional: mark as test network
evmVersion: "cancun" # Optional: network EVM hardfork (e.g., london, paris, shanghai, cancun)The supports field is optional and specifies which verification platforms are available for the network. Currently supported platforms:
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.
You can define reusable values in constants files or directly within a job.
- Top-level constants are discovered anywhere under your project root by adding YAML files with
type: "constants". - Keys must be unique across all constants files; duplicates will fail the load.
- Within jobs/templates, reference constants using bare placeholders like
{{MY_CONSTANT}}. - Job-level constants override top-level constants when names collide.
Example top-level constants file (can be placed anywhere, e.g., constants.yaml):
type: "constants"
constants:
address-zero: "0x0000000000000000000000000000000000000000"
salt-zero: "0x0000000000000000000000000000000000000000000000000000000000000000"
developer-multisig-01: "0x007a47e6BF40C1e0ed5c01aE42fDC75879140bc4"
entrypoint-4337-07: "0x0000000071727de22e5e9d8baf0edac6f37da032"Job-level constants example (defined at the top of a job):
name: "job-with-constants"
version: "1"
constants:
FEE: "1000"
ADMIN: "0x0000000000000000000000000000000000000001"
actions:
- name: "example"
template: "some-template"
arguments:
admin: "{{ADMIN}}" # resolves to job-level constant
defaultSalt: "{{salt-zero}}" # resolves to top-level constantTip: Use catapult list constants to see discovered top-level constants and any job-level constants.
You can inject secrets (like access tokens) into rpcUrl using placeholders of the form {{RPC_...}}. At load time, any placeholder whose name starts with RPC will be replaced with the value of the corresponding environment variable. Placeholders not starting with RPC are left as-is.
Example networks.yaml:
- name: "MyNet"
chainId: 999
rpcUrl: "https://node.url/something/{{RPC_URL_TOKEN}}"With an environment variable:
export RPC_URL_TOKEN="my-secret-token"Resulting rpcUrl at runtime:
https://node.url/something/my-secret-token
Notes:
- If an
{{RPC_*}}placeholder is present and the corresponding environment variable is not set, it now defaults to an empty string. This allows templates likehttps://node.url/{{RPC_TOKEN}}to collapse gracefully tohttps://node.url/without failing the load. - Multiple RPC tokens in one URL are supported, and whitespace inside the token delimiters is ignored (e.g.,
{{ RPC_TOKEN }}).
Jobs are the core deployment units. Create YAML files in the jobs/ directory:
# jobs/core-contracts.yaml
name: "core-contracts"
version: "1.0.0"
description: "Deploy core system contracts"
actions:
- name: "deploy-factory"
template: "sequence-universal-deployer-2"
arguments:
creationCode: "{{Contract(MyFactory).creationCode}}"
salt: "0"
- name: "deploy-implementation"
template: "sequence-universal-deployer-2"
depends_on: ["deploy-factory"]
arguments:
creationCode:
type: "constructor-encode"
arguments:
creationCode: "{{Contract(MyImplementation).creationCode}}"
types: ["address"]
values: ["{{deploy-factory.address}}"]
salt: "0"Jobs run on all selected networks by default. You can restrict or exclude networks for a specific job by chain ID:
name: "token-deployment"
version: "1.0.0"
# Run only on these networks (takes precedence if present)
only_networks: [1, 42161]
# Or, skip these networks (used only if only_networks is not set)
# skip_networks: [137]
actions:
- name: "deploy"
template: "erc-2470"
arguments: { /* ... */ }Rules:
- If
only_networksis set and non-empty, the job runs only on those chain IDs. - Else, if
skip_networksis set and non-empty, the job is skipped on those chain IDs. - Otherwise, the job runs on all networks selected for the run (via
networks.yamlor--network).
Jobs can declare a minimum EVM hardfork they require. When a network’s evmVersion is older than the job’s min_evm_version, the job is skipped on that network.
name: "post-shanghai-feature"
version: "1.0.0"
min_evm_version: "shanghai"
actions:
- name: "deploy"
template: "erc-2470"
arguments: { /* ... */ }Supported identifiers include: frontier, homestead, tangerine, spuriousdragon, byzantium, constantinople, petersburg, istanbul, berlin, london, paris (The Merge), shanghai, cancun, prague.
Mark a job as deprecated to opt it out of normal runs without deleting it:
name: "legacy-seed"
version: "1.2.3"
deprecated: true
actions:
- name: "noop"
type: "static"
arguments: { value: null }Behavior:
- Deprecated jobs are skipped by default when running without specifying job names.
- Explicitly targeting a deprecated job on the CLI will run it even without extra flags:
catapult run legacy-seed -k $PRIVATE_KEY. - To include all deprecated jobs in a normal run, pass
--run-deprecated:catapult run --run-deprecated -k $PRIVATE_KEY. - If a non-deprecated job depends on a deprecated job, that deprecated dependency is ALWAYS included automatically to satisfy dependencies (even without
--run-deprecated).
Templates are reusable deployment patterns. Create them in the templates/ directory:
# templates/proxy-factory.yaml
name: "proxy-factory"
type: "template"
arguments:
implementation:
type: "address"
salt:
type: "bytes32"
returns:
address:
type: "address"
setup:
- type: "job-completed"
arguments:
job: "core-contracts"
actions:
- type: "send-transaction"
arguments:
to: "{{core-contracts.deploy-factory.address}}"
data:
type: "abi-encode"
arguments:
signature: "createProxy(address,bytes32)"
values:
- "{{implementation}}"
- "{{salt}}"
skip_condition:
- type: "contract-exists"
arguments:
address:
type: "compute-create2"
arguments:
deployerAddress: "{{core-contracts.deploy-factory.address}}"
salt: "{{salt}}"
initCode:
type: "constructor-encode"
arguments:
creationCode: "{{Contract(ProxyBytecode).creationCode}}"
types: ["address"]
values: ["{{implementation}}"]
outputs:
address:
type: "compute-create2"
arguments:
deployerAddress: "{{core-contracts.deploy-factory.address}}"
salt: "{{salt}}"
initCode:
type: "constructor-encode"
arguments:
creationCode: "{{Contract(ProxyBytecode).creationCode}}"
types: ["address"]
values: ["{{implementation}}"]Notes about template files:
- The
type: "template"discriminator is optional but recommended for clarity. If provided, it must be exactlytemplate. - Templates are auto-discovered from your project
templates/folder and anytemplates/subfolders underjobs/.
Deploy all jobs to all configured networks:
catapult run --private-key YOUR_PRIVATE_KEYDeploy specific jobs:
catapult run core-contracts token-setup --private-key YOUR_PRIVATE_KEYDeploy multiple jobs using wildcards (matches job names, including nested names like sequence_v3/beta_4):
# Run all jobs whose name starts with "sequence_"
catapult run sequence_* -k $PRIVATE_KEY
# Run all jobs under a namespace/folder-like prefix
catapult run "sequence_v3/*" -k $PRIVATE_KEY
# Combine patterns and exact names; duplicates are de-duplicated
catapult run job1 job? -k $PRIVATE_KEYDeploy to specific networks:
# Comma-separated, supports chain IDs and network names (name matches include all networks with that name)
catapult run --network 1,42161 --private-key YOUR_PRIVATE_KEY
catapult run --network mainnet --private-key YOUR_PRIVATE_KEY # all networks named "Mainnet"
catapult run --network mainnet,polygon -k $PRIVATE_KEY core-contractsCommon options (run):
-p, --project <path>: Project root directory (defaults to current directory)--dotenv <path>: Load environment variables from a custom .env file (run command only)-n, --network <selectors>: Comma-separated selectors by chain ID or network name--rpc-url <url>: Run against a single custom RPC; chain ID is auto-detected. Ifnetworks.yamldefines that chain, Catapult merges yaml settings (name,supports,gasLimit,testnet,evmVersion,params) while using your RPC URL.-k, --private-key <key>: EOA private key (or setPRIVATE_KEY)--etherscan-api-key <key>: Etherscan API key (or setETHERSCAN_API_KEY)--safe-api-key <key>: Safe API key for actions withpropose: true(or setSAFE_API_KEY/SAFE_TRANSACTION_SERVICE_API_KEY)--safe-tx-service-url <url>: Custom Safe Transaction Service URL (or setSAFE_TX_SERVICE_URL)--safe-proposer-private-key <key>: Dedicated Safe owner/delegate key (or setSAFE_PROPOSER_PRIVATE_KEY; defaults to the run signer)--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--flat-output: Write outputs in a single flatoutput/directory (do not mirrorjobs/structure)--no-summary: Hide the end-of-run summary--run-deprecated: Allow running jobs markeddeprecated: true(otherwise skipped unless explicitly targeted)--no-std: Do not load built-in standard templates-v, --verbose(repeatable): Increase logging verbosity (-v,-vv,-vvv)
Examples:
- Using a custom RPC (no networks.yaml needed):
catapult run --rpc-url http://127.0.0.1:8545 -k $PRIVATE_KEY- Write outputs flat instead of mirroring
jobs/folders:
catapult run --flat-output -k $PRIVATE_KEY- Run a deprecated job explicitly:
- Without flag (explicit targeting runs it):
catapult run legacy-job -k $PRIVATE_KEY - Or include all deprecated jobs in the plan:
catapult run --run-deprecated -k $PRIVATE_KEY
- Without flag (explicit targeting runs it):
Validate your configuration without executing transactions:
catapult dry-runValidate specific jobs:
catapult dry-run core-contracts --network 1
catapult dry-run core-contracts --network polygon
catapult dry-run core-contracts --network mainnet,42161List available jobs:
catapult list jobsList detected contracts:
catapult list contractsList available templates:
catapult list templatesList configured networks:
catapult list networksList only test networks:
catapult list networks --only-testnetsList only non-test networks:
catapult list networks --only-non-testnetsList constants (top-level and per-job):
catapult list constantsSimple outputs for scripting:
# Names only, one per line
catapult list networks --simple
# Chain IDs only, one per line
catapult list networks --simple-chain-idsUtilities:
# Convert chain ID to network name
catapult utils chain-id-to-name 42161 -p ./my-project
# Export a Safe transaction artifact for Safe Transaction Builder
catapult utils safe-batch ./output --chain-id 1 --output safe-batch.json
# Multiple transactions require an explicit order
catapult utils safe-batch ./output --chain-id 1 \
--transaction upgrade-job/deploy upgrade-job/upgrade \
--output safe-batch.jsonEtherscan helpers:
# Fetch ABI from Etherscan v2
catapult etherscan abi -n 1 -a 0xdAC17F958D2ee523a2206206994597C13D831ec7 --etherscan-api-key $ETHERSCAN_API_KEY
catapult etherscan abi -n mainnet -a 0xdAC17F... --etherscan-api-key $ETHERSCAN_API_KEY
# Fetch source (standard-json or flattened) from Etherscan v2
catapult etherscan source -n 1 -a 0xdAC17F958D2ee523a2206206994597C13D831ec7 --etherscan-api-key $ETHERSCAN_API_KEY
catapult etherscan source -n mainnet -a 0xdAC17F... --etherscan-api-key $ETHERSCAN_API_KEYSource provenance helpers:
# Verify every source.yaml provenance entry in the project
catapult provenance verify
# Verify only one job's provenance entries
catapult provenance verify my-job
# Verify a job and the jobs it depends on
catapult provenance verify my-job --include-dependencies
# Generate missing build-info JSON files from source.yaml provenance
catapult provenance generateCatapult provides several built-in primitive actions:
Send a transaction to the blockchain:
- type: "send-transaction"
arguments:
to: "0x742..."
value: "1000000000000000000" # 1 ETH in wei
data: "0x..."
gasMultiplier: 1.5 # Optional: multiply gas limit by this factorThe gasMultiplier parameter is optional and allows you to tune the gas limit before sending the transaction:
- If a network gas limit is configured, it will be multiplied by this factor
- If no network gas limit is set, gas will be estimated first, then multiplied by this factor
- Must be a positive number (e.g., 1.5 for 50% more gas, 0.8 for 20% less gas)
Broadcast a pre-signed transaction:
- type: "send-signed-transaction"
arguments:
transaction: "0x..." # Raw signed transactionSets a static value that can be referenced in subsequent steps. Useful for defining constants or passing data between actions.
- type: "static"
name: "my-value"
arguments:
value: "hello world"The name field is optional. When provided, the value is stored under name.value in the context. If omitted, the value is computed but not stored. Supports all JSON data types including strings, numbers, booleans, objects, and arrays.
Example with complex data:
- type: "static"
name: "config"
arguments:
value:
endpoint: "https://api.example.com"
timeout: 5000
enabled: trueThis makes config.value.endpoint, config.value.timeout, and config.value.enabled available for use in subsequent actions.
Build a Safe transaction artifact without signing, proposing, or broadcasting it by default:
- name: "upgrade"
type: "safe-transaction"
arguments:
safe: "{{admin-safe}}"
to: "{{proxy-admin}}"
value: "0"
data:
type: "abi-encode"
arguments:
signature: "upgradeAndCall(address,address,bytes)"
values:
- "{{proxy}}"
- "{{deployment.implementation.address}}"
- "0x"
operation: 0
simulate: true
propose: false
output: truesafe, to, value, and data support normal Catapult values and resolvers. value defaults to 0, data defaults to 0x, operation defaults to CALL (0), simulate defaults to true, and propose defaults to false.
For CALL, simulation performs a read-only eth_call from the Safe address directly to the target. This checks the inner call's calldata, target-contract authorization, value, and current-state behavior. It does not run Safe.execTransaction, so it does not validate Safe signatures, threshold, nonce, guards, or refund settings. DELEGATECALL (1) artifacts require simulate: false because an inner delegatecall cannot be reproduced with a direct eth_call.
The action writes a versioned name.safeTransaction object to Catapult output. It also emits name.safeTxTo, name.safeTxValue, name.safeTxData, name.safeTxOperation, and name.executorMultisig for compatibility with existing payload consumers. Use catapult utils safe-batch to turn one or more CALL artifacts into checksummed Safe Transaction Builder JSON. When multiple artifacts exist, pass --transaction job/action ... in the required batch order.
Set propose: true to sign the Safe transaction hash with Catapult's existing run signer and submit it to the Safe Transaction Service:
- name: "upgrade"
type: "safe-transaction"
arguments:
safe: "{{admin-safe}}"
to: "{{proxy-admin}}"
data: "{{upgrade-calldata}}"
propose: true
origin: "Release automation"
# safeNonce: 42 # Optional. Defaults to the next nonce after pending proposals.
output: trueFor Safe's hosted service, provide SAFE_API_KEY (or --safe-api-key). For a self-hosted service, provide SAFE_TX_SERVICE_URL (or --safe-tx-service-url); an API key remains optional unless that service requires one. The run signer must be a Safe owner or a registered Safe Transaction Service delegate. To keep deployment and proposal authority separate, set SAFE_PROPOSER_PRIVATE_KEY to a dedicated registered delegate key; otherwise Catapult uses the normal run signer.
Proposal is off-chain: it makes the transaction visible to Safe owners but does not execute it. Catapult asks the Transaction Service to estimate safeTxGas, uses zero refund parameters, reads the Safe's canonical transaction hash on-chain, signs that hash, and submits it with the selected nonce. A successful proposal emits name.safeTransactionProposal, name.safeTxHash, name.safeTxNonce, and name.safeTxProposer. The existing unsigned artifact is still emitted.
Create a contract by sending its creation bytecode (and optional value):
- type: "create-contract"
name: "deploy-foo"
arguments:
data: "{{Contract(Foo).creationCode}}"
gasMultiplier: 1.2Make an HTTP JSON request and use the result downstream:
- type: "json-request"
name: "get-config"
arguments:
url: "https://example.com/config.json"
method: "GET"Assert an on-chain invariant or value comparison. Evaluates a condition and throws a clear error if it doesn't hold (no broadcast, ever).
The action provides two ways to obtain the ACTUAL value:
to+signature— performs aneth_call(like thecallvalue resolver)actual— resolves any Value resolver (e.g.read-balance,static, etc.)
Then provide exactly one comparator key (eq, neq, gt, lt, gte, lte) whose value is the EXPECTED result. If the comparison is false, the action throws an error that fails the run.
Example — call a view function and compare:
- type: assert
name: check-deposit-manager
arguments:
to: "{{some-proxy}}"
signature: "depositManager() returns (address)"
eq: "{{expected-deposit-manager}}"Example — resolve a value and compare:
- type: assert
name: check-balance
arguments:
actual: { type: read-balance, arguments: { address: "{{deployer}}" } }
gte: "1000000000000000000"
message: "deployer underfunded"An optional message field is included in the error output for clarity.
Catapult includes powerful value resolvers for computing complex values:
ABI-encode function call data:
data:
type: "abi-encode"
arguments:
signature: "transfer(address,uint256)"
values:
- "0x742..."
- "1000000000000000000"Encode constructor parameters with bytecode:
creationCode:
type: "constructor-encode"
arguments:
creationCode: "{{Contract(MyContract).creationCode}}"
types: ["address", "uint256"]
values: ["{{factory.address}}", "100"]Pack values per ABI types into bytes:
payload:
type: "abi-pack"
arguments:
types: ["address", "uint256"]
values: ["{{recipient}}", "{{amount}}"]Compute CREATE2 addresses:
address:
type: "compute-create2"
arguments:
deployerAddress: "{{factory.address}}"
salt: "{{salt}}"
initCode: "{{creationCode}}"Perform mathematical operations:
amount:
type: "basic-arithmetic"
arguments:
operation: "add"
values: ["{{current_balance}}", "1000000000000000000"]Read account balance:
balance:
type: "read-balance"
arguments:
address: "{{deployer_address}}"Make view/pure function calls:
result:
type: "call"
arguments:
to: "{{contract.address}}"
signature: "getName()"
values: []Read a raw EVM storage slot via eth_getStorageAt:
storageValue:
type: "get-storage-at"
arguments:
address: "{{contract.address}}"
slot: "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc" # EIP-1967 implementation slotThe slot can be a hex string, a number, or a reference to another value. Returns the 32-byte storage value as a hex string.
Compute EVM storage slots for the common Solidity storage layouts. The result is always a 32-byte, 0x-prefixed hex string, so it can be fed directly into get-storage-at or nested as the slot of another compute-slot (e.g. for nested mappings). Select a layout with kind:
mapping — value slot of mapping[key] (keccak256(h(key) . slot)):
slot:
type: "compute-slot"
arguments:
kind: "mapping"
slot: 0 # declaration slot of the mapping
key: "{{owner}}"
keyType: "address" # optional, default "uint256"; "string"/"bytes" keys are packedNested mappings (balances[a][b]) are expressed by nesting compute-slot in the slot field:
slot:
type: "compute-slot"
arguments:
kind: "mapping"
key: "{{spender}}"
keyType: "address"
slot:
type: "compute-slot"
arguments:
kind: "mapping"
slot: 1
key: "{{owner}}"
keyType: "address"dynamic-array — element slot of a dynamic array (keccak256(slot) + index * elementSize). The array length lives at slot itself:
slot:
type: "compute-slot"
arguments:
kind: "dynamic-array"
slot: 3
index: 4 # optional, default 0
elementSize: 1 # optional slots-per-element, default 1struct-field — a struct field or fixed-array element (slot + offset):
slot:
type: "compute-slot"
arguments:
kind: "struct-field"
slot: "{{structBase}}"
offset: 2erc7201 — ERC-7201 namespaced storage root (keccak256(abi.encode(uint256(keccak256(id)) - 1)) & ~0xff):
slot:
type: "compute-slot"
arguments:
kind: "erc7201"
id: "openzeppelin.storage.Ownable"eip1967 — well-known EIP-1967 proxy slot (keccak256("eip1967.proxy.<name>") - 1), where name is implementation, admin, or beacon:
implementation:
type: "get-storage-at"
arguments:
address: "{{proxy.address}}"
slot:
type: "compute-slot"
arguments:
kind: "eip1967"
name: "implementation"Read a value from a JSON object at a given path:
tokenAddress:
type: "read-json"
arguments:
json: "{{get-config.response}}"
path: "tokens.usdc.address"Slice hex strings by byte offsets (supports negative indexes and range syntax):
trimmedPatchData:
type: "slice-bytes"
arguments:
value:
type: "read-json"
arguments:
json: "{{get-guard-v1-signature.response}}"
path: "txs.data"
range: ":-1" # Drop the trailing byte from the payloadYou can also provide explicit start and end byte positions (end is exclusive). Negative indexes count from the end of the byte array, so end: -1 trims the last byte and start: -32 keeps the final 32 bytes. range accepts either start:end or the bracket form [start:end].
Read the raw contents of a file as a value. This is the home for large, opaque, per-execution operational blobs (e.g. packed multisig signatures) that don't belong in constants and aren't typed build artifacts. The path is resolved relative to the directory of the job/template that uses it and is confined to the project root (absolute paths and .. escapes are rejected):
# Read packed Safe owner signatures written to a gitignored file next to the job
signatures:
type: "read-file"
arguments:
path: "signatures.hex"
encoding: "hex" # "utf8" (default), "hex", or "json"encoding: "utf8" returns the text with a single trailing newline trimmed; "hex" validates and normalizes to a 0x-prefixed lowercase hex string; "json" parses the file and returns the resulting value (composes with read-json).
Join resolved parts into a single string. Use this for URL/path templating instead of embedding {{...}} inside a longer literal (which is not interpolated — a {{ref}} is only resolved when it is the entire value):
url:
type: "concat"
arguments:
values:
- "https://safe-transaction-mainnet.safe.global/api/v1/multisig-transactions/"
- "{{safe-tx-hash}}"
- "/"
separator: "" # optional, defaults to "" (direct concatenation)Verify deployed contracts on block explorers:
- type: "verify-contract"
arguments:
address: "{{deploy-factory.address}}"
contract: "{{Contract(MyContract)}}" # Reference to the contract to verify
constructorArguments: "0x000000000000000000000000..." # Optional hex-encoded args
platform: "etherscan_v2" # Optional, defaults to "all" (tries all configured platforms)By default, verification failures will cause the deployment to exit with an error code. To continue deployment even when verification fails and receive a comprehensive warning report at the end, use the --ignore-verify-errors flag:
catapult run --ignore-verify-errorsThis is useful when:
- You want to complete all deployments even if some contract verifications fail
- Working with networks where verification platforms may be unreliable
- Running in CI/CD environments where you prefer warnings over hard failures for verification issues
When enabled, verification errors are converted to warnings and a detailed report is shown at the end of the deployment with all verification failures.
Avoid redundant operations with skip conditions:
Skip if contract exists at address:
skip_condition:
- type: "contract-exists"
arguments:
address: "{{computed_address}}"Skip if another job is completed:
skip_condition:
- type: "job-completed"
arguments:
job: "prerequisite-job"In addition to skip_condition, jobs support a skip_if field for pure gate semantics:
- Evaluated ONCE, BEFORE the job runs (the pre-skip decision)
- If ANY condition in
skip_ifis true → skip the whole job (statusskipped) - NEVER post-execution-checked (this is the key difference from
skip_condition)
Use skip_if for jobs that generate artifacts (e.g., Safe/multisig transaction payloads for human execution out-of-band) and should skip when already in the desired state, without requiring convergence within the run itself.
name: "generate-upgrade-payload"
version: "1.0.0"
skip_if:
- type: "contract-exists"
arguments:
address: "{{computed_upgrade_address}}"
actions:
- name: "generate"
type: "static"
arguments:
value: "upgrade-payload-data"Combining skip_condition and skip_if:
If both are present, the job is skipped if ANY condition in either array is true at pre-skip time. Only skip_condition is post-execution-checked.
name: "hybrid-job"
version: "1.0.0"
skip_condition:
- type: "contract-exists"
arguments:
address: "{{deployed_address}}"
skip_if:
- type: "job-completed"
arguments:
job: "setup-job"
actions:
- name: "deploy"
template: "erc-2470"
arguments:
creationCode: "{{Contract(MyContract).creationCode}}"
salt: "0"Catapult includes several standard templates:
sequence-universal-deployer-2: Deploy contracts using Sequence's Universal Deployer v2nano-universal-deployer: Deploy contracts using the Nano Universal Deployerarachnid-deterministic-deployment-proxy: Deploy contracts via Arachnid's CREATE2 proxy at0x4e59…, with automatic factory bootstrappingerc-2470and raw variant: CREATE2 Deployer (singleton factory)assured-deployment: Helper to ensure a contract is deployed at a specific addressmin-balance: Ensure minimum balance for any given addresssafe-exec-transaction: Assemble and broadcast a fully-signed Gnosis SafeexecTransaction(see below)- Raw building blocks:
raw-sequence-universal-deployer-2,raw-nano-universal-deployer,raw-erc-2470
Broadcasts a fully-signed Gnosis Safe transaction on-chain ("Shape 1" relay) instead of stopping at calldata for a human to paste into the Safe UI. It packs the owner signatures the Safe already collected into execTransaction and sends the outer transaction with the configured EOA as relayer.
The packed signatures are passed as the signatures argument. Where they come from is the caller's choice — the two common sources:
Offline / air-gapped (signatures collected into a gitignorable file):
- name: "relay"
template: "safe-exec-transaction"
arguments:
safe: "{{safe_address}}"
to: "{{target}}"
data: "{{inner_calldata}}"
operation: "0" # 0 = CALL, 1 = DELEGATECALL
signatures:
type: "read-file"
arguments: { path: "signatures.hex", encoding: "hex" }Safe Transaction Service (hosted flow — the service returns a top-level pre-packed signatures field):
- name: "fetch-safe-tx"
type: "json-request"
arguments:
url:
type: "concat"
arguments:
values:
- "https://safe-transaction-mainnet.safe.global/api"
- "/v1/multisig-transactions/"
- "{{safe_tx_hash}}"
- "/"
- name: "relay"
template: "safe-exec-transaction"
arguments:
safe: "{{safe_address}}"
to: "{{target}}"
data: "{{inner_calldata}}"
operation: "0"
signatures:
type: "read-json"
arguments: { json: "{{fetch-safe-tx.response}}", path: "signatures" }The template only assembles and broadcasts; it does not impose a post-execution skip condition, because the desired state of a Safe relay is the effect of the inner call. Wrap the calling job with skip_if observing that on-chain effect (e.g. owner() == newOwner) for idempotent, re-runnable convergence.
Catapult automatically discovers and indexes contract artifacts in your project. It supports:
- JSON artifacts (Hardhat, Truffle, Foundry)
- Nested directory structures
- Hash-based contract references
- Path-based contract references
- Name-based contract references
Reference contracts in your YAML using the new unified Contract() syntax:
creationCode: "{{Contract(path/to/MyContract).creationCode}}"
# or
creationCode: "{{Contract(0x1234...hash).creationCode}}"Build-info files can carry optional source provenance through a nearby source.yaml
sidecar. Deployment runs still use committed artifacts; Catapult does not rebuild
from the source repository during catapult run.
jobs/my-stack/build-info/rc-5/
├── stage1.json
└── source.yaml
type: source
build_info:
"./stage1.json":
repo: "https://github.com/0xsequence/wallet-contracts-v3"
ref: "v3.0.0-rc.5"
commit: "0d9061f229da73edae890e6fdd1fbf753028df6d"
build: "forge build --build-info"Catapult can use the same provenance to rebuild and compare build-info files on demand:
# Rebuild each source provenance entry and compare it with the committed file
catapult provenance verify
# Scope to one job, or include that job's dependencies
catapult provenance verify my-job
catapult provenance verify my-job --include-dependencies
# Clone/build from provenance and write missing build-info files
catapult provenance generate
catapult provenance generate my-job --include-dependenciesprovenance generate skips existing build-info files by default; pass --force
to overwrite them. Both commands clone the configured repo, check out ref or
commit, run the build command in that checkout, and look for generated
build-info/*.json files. If the build produces more than one build-info file,
Catapult selects by matching the committed build-info id, then by filename; if
neither is unique, the entry fails with an ambiguity error.
Because provenance compares the entire build-info JSON (including compiler
settings such as evmVersion), the rebuild must use the same toolchain that
produced the committed file. Add an optional image field to run the build
command inside a pinned Docker image instead of on the host:
type: source
build_info:
"./stage1.json":
repo: "https://github.com/0xsequence/wallet-contracts-v3"
commit: "0d9061f229da73edae890e6fdd1fbf753028df6d"
image: "ghcr.io/foundry-rs/foundry:v1.5.1"
build: "forge build --build-info"When image is set, Catapult runs docker run <image> with the checkout
bind-mounted at /workspace (also the working directory and $HOME), the build
command executed via sh -c, and — on POSIX hosts — the container running as the
caller's uid:gid so the generated files are owned by you and the temporary
checkout can be cleaned up. This keeps the toolchain pinned per entry (different
build-info files can use different images) and leaves the host/runner untouched;
it requires Docker to be installed and running. The image field is also
supported in per-contract contracts overrides.
If a build-info file needs a per-contract override, key it by fully-qualified contract name:
type: source
build_info:
"./stage1.json":
repo: "https://github.com/0xsequence/wallet-contracts-v3"
commit: "0d9061f229da73edae890e6fdd1fbf753028df6d"
contracts:
"src/Stage1Module.sol:Stage1Module":
ref: "stage1-special"After successful deployment, Catapult generates JSON files in the output/ directory for each job. The output format is optimized to reduce repetition:
Networks with identical deployment outputs are grouped together:
{
"jobName": "core-contracts",
"jobVersion": "1.0.0",
"lastRun": "2025-01-15T10:30:45.123Z",
"networks": [
{
"status": "success",
"chainIds": ["1", "42161", "137"],
"outputs": {
"deploy-factory.address": "0x742d35Cc6ab8b3c7B3d4B8b3aB4c8f9e9C8e8aB6",
"deploy-factory.txHash": "0xabc123...",
"deploy-implementation.address": "0x123abc..."
}
}
]
}When deployments fail on specific networks, each failure is recorded separately:
{
"jobName": "core-contracts",
"jobVersion": "1.0.0",
"lastRun": "2025-01-15T10:30:45.123Z",
"networks": [
{
"status": "success",
"chainIds": ["1", "42161"],
"outputs": {
"deploy-factory.address": "0x742d35Cc6ab8b3c7B3d4B8b3aB4c8f9e9C8e8aB6"
}
},
{
"status": "error",
"chainId": "137",
"error": "Transaction failed: insufficient funds"
}
]
}This format ensures:
- Minimal repetition: Successful deployments with identical outputs across multiple networks are grouped together
- Clear error tracking: Individual network failures are clearly documented
- Scalability: The format remains readable even with deployments across dozens of networks
Output layout and selection:
- By default, output files mirror the structure under
jobs/(e.g.,jobs/core/job.yaml->output/core/job.json). Use--flat-outputto write all job JSON files directly underoutput/. - You can control which action outputs are persisted per job using the
outputflag on actions:output: trueto include all outputs for that actionoutput: falseto exclude outputs for that actionoutput: { key1: true, key2: true }to include only specific keys from that action (e.g.,txHash,address)
PRIVATE_KEY: Signer private key (alternative to--private-key)ETHERSCAN_API_KEY: API key for Etherscan v2 verification (alternative to--etherscan-api-key)SAFE_API_KEYorSAFE_TRANSACTION_SERVICE_API_KEY: Safe API key for optional proposalsSAFE_TX_SERVICE_URL: Custom Safe Transaction Service URLSAFE_PROPOSER_PRIVATE_KEY: Dedicated Safe owner/delegate key used only for proposals
You can load environment variables from a file using --dotenv <path> on the run command (defaults to .env in the current directory when provided).
- Node.js >= 22.0.0
- npm or yarn
# Install dependencies
npm install
# Build the project
npm run build
# Run in development mode
npm run dev
# Watch for changes
npm run watchnpm run build- Compile TypeScript to JavaScriptnpm run dev- Run the CLI in development mode with ts-nodenpm run watch- Watch for changes