An Aragon OSx plugin that lets a DAO execute actions on another chain.
A DAO proposal on the origin chain calls forwardMessage with an encoded Action[]. The
CrossChainController wraps it in a Transaction envelope and hands it to a bridge
adapter. On the destination chain, the adapter authenticates the delivery and passes it to
the CrossChainController there, which runs the actions through an Executor.
The controller is the single entry point for the send direction and holds the lane
configuration: which local adapter serves a chain, and which remote address to send to.
Inbound messages enter at the local adapter, which authenticates them and is the only
caller receiveMessage accepts.
Adapters hold no lane configuration, but they do carry their own trusted-remote map plus an
immutable router, fee token and ChainIdRegistry binding — none of which has a setter. So
swapping bridges, or fixing any adapter-level value, means deploying a new adapter on each
side of the lane and updating both controllers' config.
The chain-id table is the one exception, and deliberately so: the binding is immutable,
the table behind it is governed. Adding a chain to a live deployment is one
setChainIdPair on the registry rather than a replacement adapter on both sides. The price
is that whoever holds MANAGE_CHAIN_ID_REGISTRY_PERMISSION can repoint a live lane at a
different chain in a single call — see ChainIdRegistry.
flowchart LR
DAO[DAO] -->|forwardMessage| CCC1[CrossChainController]
CCC1 -.->|sendMessage<br/>DELEGATECALL| ADP1[Adapter]
ADP1 -->|bridge| ADP2[Adapter]
ADP2 -->|receiveMessage| CCC2[CrossChainController]
CCC2 -->|execute| EXEC[Executor]
EXEC --> TARGET[Target contracts]
The send path is delegatecalled, so the adapter's code runs as the controller: the bridge
fee is paid from the controller's own balance and the bridge attributes the message to the
controller's address. No protocol path routes funds through an adapter — the controller
pays, and the adapter's balance stays empty — so pre-fund the controller, never the
adapter: assets sent directly to an adapter are stranded, as adapters have no rescue path.
The destination trusts the remote controller rather than the adapter.
The receive path is a plain CALL, so the adapter runs as itself and reads its own
trusted-remote map.
A lane may also target the chain it lives on. The controller's config allows it, but
delivering without a bridge needs a purpose-built loopback adapter — none ships in src/,
and CCIPAdapter rejects a same-chain destination because CCIP serves no lane to its own
chain. See Same Chain Delivery.
| Contract | Role |
|---|---|
CrossChainController |
The plugin. Send path, receive path, retry/cancel, lane config, pausing, fee pre-funding and sweeping. |
CrossChainControllerSetup |
OSx plugin setup. Deploys the proxy and declares the permissions to grant or revoke. |
Executor |
Ownable variant of the OSx commons executor, so only its owning controller can execute. |
BaseAdapter |
Shared adapter logic: controller binding, trusted remotes, execution-context checks. |
CCIPAdapter |
Chainlink CCIP transport. |
IBaseAdapter |
The interface every transport must satisfy. |
ChainIdRegistry |
The DAO-governed chain id ↔ bridge-selector table an adapter resolves its lanes through. One per bridge protocol. |
Transaction |
The message envelope and its lifecycle state. |
Requires Foundry and
just. Task running and per-network env are
handled by just-foundry, vendored as a
submodule under lib/just-foundry.
git submodule update --init
just init sepolia # or mainnet, base, ... — activates the network config
just help # list every recipeforge build
forge fmt
just test # the whole suite
just test-e2e # the end-to-end suites
just test-fork # end-to-end against real CCIP routers; needs RPC endpointsThe fork tests are not excluded by the first two targets: they skip themselves
unless MAINNET_RPC_URL (or RPC_URL) and BASE_RPC_URL are set, in which case
they reach the network. just test-fork refuses to run without both.
Unit suites live in test/unit/, mostly one file per function, plus a few
cross-cutting suites. The end-to-end suites carry a message the whole way through
both stacks: see test/e2e/README.md.
CrossChainController is an OSx plugin, so it needs a PluginRepo before any DAO can
install it. script/CreateRepo.sol deploys the implementation, the setup contract and the
repo in one go.
Copy .env.example to .env and fill it in, activate the target network, then
simulate and broadcast:
just switch sepolia # or any other supported network
just env # verify the resolved env (RPC, factory addresses, ...)
just predeploy # simulate
just deploy # broadcast and verify
just verify # re-verify from the last broadcastDEPLOYER_KEY and ETHERSCAN_API_KEY come from .env (or from
vars); every other network parameter,
including PLUGIN_REPO_FACTORY_ADDRESS, is supplied by the active network
config under lib/just-foundry/networks/.
Installing the plugin on a DAO goes through the OSx PluginSetupProcessor, pointing at
that repo. Installation parameters are (executor, guardian, minFailedMessageGas) — see
CrossChainControllerSetup.encodeInstallationParameters. Passing address(0) as the
executor makes the setup deploy a dedicated Executor owned by the plugin.
Both controllers must exist before either adapter is deployed: an adapter takes its trusted remote in the constructor and has no setter, and that trusted remote is the other chain's controller.
- Install
CrossChainControlleron both chains. - Deploy a
ChainIdRegistryon each chain, grantMANAGE_CHAIN_ID_REGISTRY_PERMISSIONon it to that chain's DAO, and seed the remote chain's(chainId, selector)pair. This comes before the adapter: the adapter takes the registry in its constructor, has no setter, and rejects one with no code. - Deploy an adapter on each chain, pointing at the local controller and the local registry, and trusting the remote controller.
- Call
updateConfigon each controller with an array of remote chain ids and a matching array of{ localAdapter, remoteAdapter }configs — it is batch-only, and both arrays must be the same length.
Note the asymmetry: updateConfig records the remote adapter (the bridge-level
receiver), while the adapter constructor records the remote controller (the
authenticated sender). Confusing the two is the most common wiring mistake. Pointing the
adapter's trusted remote at the remote adapter makes every inbound message fail with
REMOTE_NOT_TRUSTED. Getting updateConfig's remoteAdapter wrong is worse: nothing fails
early, the send succeeds and the fee is spent. What happens on arrival depends on the
address — a non-contract is skipped by CCIP and the message is silently lost, the remote
controller reverts the delivery, another adapter rejects it — but in every case the
destination controller records nothing.
Full step-by-step instructions are in Deployment.
There are two separate pots.
The controller pays bridge fees from its own balance. Pre-fund it with the fee token
(address(0) means native currency) and use quoteFee to check the required amount
against what is held. sweep moves the funds back out.
The executor pays for the actions themselves. Messages carry instructions, never funds,
so whatever an action spends must be available when it runs — normally by pre-funding the
executor. An underfunded action is captured as Delivered and can be retried once funded.
Under the shipped wiring the pots are isolated: a dedicated Executor holds none of the
controller's permissions, so a payload cannot reach the fee float. They stop being isolated
when the executor holds permissions on the controller — under executor = dao an action
runs as the DAO and inherits its SWEEP/FORWARD_MESSAGE permissions, and an executor
granted FORWARD_MESSAGE_PERMISSION can start a chained hop paid from the local float. See
Asset-bearing actions for that and for the ERC20 caveat.
- specs/SPEC.md — protocol behaviour, permissions, function reference and operational runbooks.
- specs/AUDIT_SPEC.md — audit scope.
Report vulnerabilities to sirt@aragon.org.
AGPL-3.0-or-later, except src/adapters/IBaseAdapter.sol, which is MIT.