Migrating from OpenZeppelin Defender
OpenZeppelin Defender was sunset on July 1, 2026. Its official replacement is a pair of open-source, self-hosted services: openzeppelin-relayer and openzeppelin-monitor (both AGPL, plus Redis). Neither replaces Actions: OZ's own migration guide says that logic "will need to be manually recreated". Monitor notifies but never sends; Relayer plugins are TypeScript functions invoked over HTTP, with no trigger binding or scheduler. rflow covers Monitors, Relayers and the missing Actions layer in one binary. This page maps every Defender concept to its rflow equivalent and shows the one-command importer.
The one command
If you managed Defender with the
defender-as-code serverless
plugin, point the importer at your serverless.yml:
rflow import defender ./serverless.yml --output ./my-projectIt scaffolds a complete project (rflow.yaml, .env / .env.example, abis/,
docker-compose.yml, MIGRATION-NOTES.md) and prints a summary of what mapped,
what needs review, and what could not convert. The generated project
passes rflow validate as-is: anything with no rflow equivalent stays in
the file as a # TODO(rflow import) comment, never silently dropped.
status | source item | result
mapped | monitor 'large-usdc-transfers' event ... | → workflow 'large-usdc-transfers'
mapped | relayer 'relayer-2' | merged into relayers.payout-relayer
needs attention | action 'daily-reporter' | cron mapped; the JS body does NOT auto-convert
needs attention | action 'action-webhook' | webhook trigger mapped; the JS body does NOT auto-convert
imported into ./my-project - 9 mapped, 12 need attention, 3 not migratedConcept map — Defender gave you X, in rflow it is Y
| Defender | rflow | Imported automatically? |
|---|---|---|
| Monitor (event condition) | workflow with an event: trigger | ✅ one workflow per event signature |
Monitor condition expression (value > 100) | where: "${{ trigger.args.value > 100 }}" | ✅ best-effort translated, flagged for review |
Monitor confirm-level | trigger confirmations: N | finalized | ✅ |
| Monitor function / transaction conditions | no direct equivalent; watch an emitted event, or use a read: trigger (poll a view function, fire on a threshold) | ❌ TODO comment + note |
| Notification channel (slack / telegram / discord) | notifications.channels + notify steps | ✅ credentials via .env |
| Notification channel (pagerduty / opsgenie) | native pagerduty: / opsgenie: channels | ✅ credentials via .env |
| Notification channel (email / datadog) | an http_call to the provider's API | ❌ noted |
| Notification channel (webhook) | http_call step with templated body + optional HMAC | ❌ noted (one-liner to add) |
| Relayer | relayers: entry | ✅ |
address-from-relayer | one relayer on several networks; rflow creates the wallet once and clones the address everywhere | ✅ merged into a single entry |
| Relayer policy (gas cap, receiver allowlist) | relayers.<name>.policy (max_gas_price, whitelist_receivers) + per-workflow permissions.max_value_per_tx | ❌ noted with the exact keys to set |
Relayer min-balance | automatic_top_up passthrough on the network (relayers fund each other / from a Safe) | ❌ noted |
| Monitor alert threshold (X events in Y) | trigger throttle: | ✅ semantics caveat flagged: Defender alerts on the Nth hit, rflow runs the first N then cools down |
| Action (schedule: cron or frequency) | workflow with a cron: trigger | ✅ trigger only; see below |
| Action JavaScript body | native steps (read, http_call, send_transaction, notify) where they fit; otherwise keep the JS and run it from a command: step | ❌ no auto-convert; a command: step runs the preserved script as-is (it decides/prepares, a later send_transaction moves money). A TODO placeholder keeps the workflow valid |
| Action (webhook trigger) | a webhook: trigger (path + optional HMAC auth) | ✅ trigger only: point callers at rflow's port; the JS body stays a TODO |
| Action environment variables | constants: | ✅ |
| Secrets | secrets: backed by .env placeholders | ✅ names only: Defender never exposes secret values; re-enter them |
| Contracts + ABIs | contracts: registry; ${file(...)} ABIs are copied, missing ones are synthesized from the monitored event signatures | ✅ |
The two honest caveats
Your relayer keys do not come with you. Defender custodied the signing keys;
they were never exportable. The importer scaffolds a fresh
signer: (dev mnemonic via .env, or swap in AWS KMS / GCP /
Turnkey / Fireblocks / Privy / PKCS#11). You get new relayer addresses. Fund
them, and update any onchain allowlists that referenced the old Defender addresses.
rflow relayers ls shows the new addresses after first start.
JavaScript does not become YAML. The importer converts the trigger
(schedule → cron) and leaves a placeholder step with a TODO pointing at your
source file. For action bodies that fit the native steps, an onchain check becomes
read: + assert:, an API call becomes http_call:, a transaction becomes
send_transaction: (with pre-flight simulation and idempotency keys for free).
Worked example
A typical Defender monitor:
monitors:
large-usdc-transfers:
name: 'Large USDC Transfers'
type: 'BLOCK'
network: 'mainnet'
addresses:
- '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'
confirm-level: 1
notify-config:
channels:
- ${self:resources.notifications.slack-1}
conditions:
event:
- signature: 'Transfer(address,address,uint256)'
expression: 'value > 10000000000'becomes:
rflow_version: 1
name: defender-import
config:
port: 3940
db_connection: ${DATABASE_URL}
networks:
- name: ethereum
chain_id: 1
rpc: ${ETHEREUM_RPC}
contracts:
large-usdc-transfers-target:
abi: ./abis/large-usdc-transfers-target.json # synthesized from the event signature
addresses:
ethereum: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
notifications:
channels:
workspace-slack:
slack:
webhook_url: ${WORKSPACE_SLACK_WEBHOOK_URL}
workflows:
large-usdc-transfers:
trigger:
event:
contract: large-usdc-transfers-target
name: Transfer
network: ethereum
where: "${{ trigger.args.value > 10000000000 }}"
confirmations: 1
start_block: latest
end_block: live
steps:
- id: notify-workspace-slack
notify:
channel: workspace-slack
message: "Large USDC Transfers: Transfer on ethereum - tx ${{ trigger.tx_hash }}"
on_failure: dead_letterThen:
cd my-project
docker compose up -d # postgres
vim .env # rpc urls, slack webhook, signer
rflow validate
rflow startHolding OpenZeppelin Monitor (OSS) configs instead?
OpenZeppelin's suggested Defender replacement,
openzeppelin-monitor, uses
JSON monitor files close to what rflow imports:
match_conditions.events[].signature/expression map 1:1 onto event: triggers with
where:, and its triggers (slack/telegram/discord/webhook/script) map onto
notifications.channels and steps. A dedicated rflow import oz-monitor is planned;
until it lands, translate with the table above. Monitor paired with the
open-source OpenZeppelin Relayer can send transactions, but you wire
everything between them yourself (Monitor webhook → your glue → Relayer HTTP
API, plus your own retry and dedupe). In rflow the same file that watches the
event submits the response transaction with a persisted idempotency key and
a recovery journal.
What you gain in the move
- Durable execution — deduplicated trigger claims, journaled steps and idempotent relayer submissions. HTTP, notifications and commands need their own external-effect idempotency. See Reliability.
- Pre-flight simulation on every send by default, plus
gas caps,
assert_simandrecheck. - Reorg-aware confirmations per trigger — alert at head, pay at depth, and
on_reorg:compensations Defender never had. See Reorgs. - Historical backfill + live tail in one config (
start_block: earliest), plusrflow replayto rehearse historical triggers. Reads and simulations use the configured RPC state, not an automatic historical snapshot. - Human approval gates on transactions; see Approvals.
- Your choice of custody — choose the signing provider and account that your deployment uses, including your own cloud KMS or a supported remote custodian.