Skip to content

A hands-on tour of OSx

The fastest way to feel how OSx works: in one Foundry test you'll deploy a real DAO, fund it, make it perform an on-chain action, and then watch the permission system reject the same action from the wrong caller.

By the end you'll have seen the three things a DAO is, a treasury, an executor, and its own permission database, in motion, and you'll know exactly what the later guides build on.

What you need

Do the one-time Setup first, a plugin project pointed at a chain where OSx is deployed. This guide reads just the DAOFactory address (DAO_FACTORY_ADDRESS); nothing plugin-specific.

We fork rather than deploy the whole protocol locally: standing up OSx from scratch is the Protocol Factory's job, not something you need for a first look.

Step 1, deploy a bare DAO

DAOFactory.createDao creates, registers, and self-governs a DAO in one transaction. Pass an empty plugin array and the factory grants you (msg.sender) EXECUTE_PERMISSION_ID on the new DAO, so a plugin-less DAO is yours to drive directly until you install governance.

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

import {Test} from "forge-std/Test.sol";
import {DAO} from "@aragon/osx/core/dao/DAO.sol";
import {DAOFactory} from "@aragon/osx/framework/dao/DAOFactory.sol";
import {Action} from "@aragon/osx/common/executors/IExecutor.sol";

contract OsxInOneSitting is Test {
    DAOFactory factory = DAOFactory(vm.envAddress("DAO_FACTORY_ADDRESS")); // exported by just-foundry
    DAO dao;

    function setUp() public {
        vm.createSelectFork(vm.envString("RPC_URL"));

        // No plugins -> the caller (this test) receives EXECUTE_PERMISSION_ID on the DAO.
        DAOFactory.DAOSettings memory daoSettings = DAOFactory.DAOSettings({
            trustedForwarder: address(0),
            daoURI: "",
            subdomain: "", // empty = skip ENS registration
            metadata: ""
        });
        DAOFactory.PluginSettings[] memory noPlugins = new DAOFactory.PluginSettings[](0);

        (dao, ) = factory.createDao(daoSettings, noPlugins);
    }
}

You now hold the most consequential permission in the protocol. That is fine for a demo and dangerous as an end state, in a real DAO, governance (a plugin) holds EXECUTE, not a person. See how ROOT is bootstrapped safely.

Step 2, fund it, then make it act

A DAO holds assets like any account, and does things by running a batch of actions (one Action is a single external call (to, value, data)). In one test, fund the DAO, then have it transfer 1 ETH back out. Add this function to the contract:

solidity
function test_fundAndSpend() public {
    // Fund the treasury. Native coin sent directly hits receive(), which just records it.
    vm.deal(address(this), 10 ether);
    (bool funded, ) = address(dao).call{value: 5 ether}("");
    assertTrue(funded);
    assertEq(address(dao).balance, 5 ether);

    // Ask the DAO to send 1 ETH to Bob.
    address payable bob = payable(address(0xB0B));
    Action[] memory actions = new Action[](1);
    actions[0] = Action({to: bob, value: 1 ether, data: ""});

    // callId is a caller-defined tag (a proposal id, normally); allowFailureMap = 0 = all must succeed.
    dao.execute({_callId: bytes32(0), _actions: actions, _allowFailureMap: 0});

    assertEq(bob.balance, 1 ether);
    assertEq(address(dao).balance, 4 ether);
}

There are two things to notice. Funding is permissionless, anyone can pay a DAO, but assets leave only through execute, which is permission-gated. The transfer runs as the DAO, at Bob's address msg.sender is the DAO and the ether is the DAO's, because execute uses call, not delegatecall (the same reason a DAO can call itself to change its own permissions, see why it's a call).

Step 3, watch the permission gate

The transfer worked only because you hold EXECUTE_PERMISSION_ID. The same call from any other address is denied by the permission system before a single action runs:

solidity
function test_strangerCannotExecute() public {
    Action[] memory actions = new Action[](1);
    actions[0] = Action({to: address(0xB0B), value: 0, data: ""});

    vm.prank(address(0xDEAD));   // an address with no permission
    vm.expectRevert();           // reverts with an Unauthorized-family error before executing
    dao.execute(bytes32(0), actions, 0);
}

That revert is OSx's access control. Every privileged call in the protocol, on the DAO and on every plugin, resolves through this one check: is (where, who, permissionId) granted? See how a decision is made.

What you just saw

  • A DAO is one contract that holds assets, executes actions, and owns the rules for who may do what, the DAO.
  • Actions are how it acts, atomically and as itself, actions and execution.
  • Nothing privileged happens without a matching grant, permissions.

Next