Build a plugin
A plugin is two artifacts: the plugin contract (your logic, gated by permissions) and its PluginSetup (the recipe that deploys the plugin and declares exactly which permissions to grant on install and revoke on uninstall). The setup is what the PSP applies in the temporary-ROOT window; get it right and your plugin installs, updates, and uninstalls cleanly everywhere.
Start from the Foundry plugin template (the Setup scaffold): it gives you starter contracts for all three base types (MyStaticPlugin, MyCloneablePlugin, MyUpgradeablePlugin), a MyPluginSetup, tests, and deploy scripts. This guide uses the UUPS-upgradeable variant. (Imports below are the template's.)
Step 1, the plugin contract
Inherit PluginUUPSUpgradeable (it gives you dao(), the auth modifier, and UUPS upgrade authorization), initialize with __PluginUUPSUpgradeable_init(_dao), declare your permission ids, and gate privileged functions with auth:
// SPDX-License-Identifier: AGPL-3.0-or-later
pragma solidity ^0.8.17;
import {DAO, IDAO, Action} from "@aragon/osx/core/dao/DAO.sol";
import {PluginUUPSUpgradeable} from "@aragon/osx/common/plugin/PluginUUPSUpgradeable.sol";
contract MyUpgradeablePlugin is PluginUUPSUpgradeable {
bytes32 public constant MANAGER_PERMISSION_ID = keccak256("MANAGER_PERMISSION");
uint256 public number; // added in build 1
function initialize(IDAO _dao, uint256 _initialNumber) external initializer {
__PluginUUPSUpgradeable_init(_dao);
number = _initialNumber;
}
/// Caller must hold MANAGER_PERMISSION_ID *on this plugin*, resolved against the DAO.
function setNumber(uint256 _number) external auth(MANAGER_PERMISSION_ID) {
number = _number;
}
/// Make the DAO act. Requires the plugin to hold EXECUTE_PERMISSION_ID on the DAO.
function resetDaoMetadata() external {
Action[] memory actions = new Action[](1);
actions[0].to = address(dao());
actions[0].data = abi.encodeCall(IDAO.setMetadata, (""));
DAO(payable(address(dao()))).execute(bytes32(block.timestamp), actions, 0);
}
uint256[49] private __gap; // storage gap for safe upgrades
}There are three things to note. auth(MANAGER_PERMISSION_ID) doesn't check a local list, it asks the DAO "does the caller hold this on this plugin" (the plugin is the where). A plugin makes the DAO act by building actions and calling dao.execute, which only works if the plugin holds EXECUTE_PERMISSION_ID on the DAO, and that grant comes from the setup below. The __gap preserves storage layout across upgrades.
Step 2, the PluginSetup
The setup deploys the plugin and returns the exact permissions the install needs. prepareInstallation decodes its install data, deploys the proxy, and lists the grants; prepareUninstallation lists the matching revokes. This is the ABI and the permission set that Deploy your first DAO and Install a plugin feed to the PSP:
// SPDX-License-Identifier: AGPL-3.0-or-later
pragma solidity ^0.8.17;
import {IDAO, DAO} from "@aragon/osx/core/dao/DAO.sol";
import {PluginSetup} from "@aragon/osx/common/plugin/setup/PluginSetup.sol";
import {IPluginSetup} from "@aragon/osx/common/plugin/setup/IPluginSetup.sol";
import {PermissionLib} from "@aragon/osx/common/permission/PermissionLib.sol";
import {ProxyLib} from "@aragon/osx/common/utils/deployment/ProxyLib.sol";
import {MyUpgradeablePlugin} from "../MyUpgradeablePlugin.sol";
contract MyPluginSetup is PluginSetup {
constructor() PluginSetup(address(new MyUpgradeablePlugin())) {} // the implementation to clone/proxy
function prepareInstallation(address _dao, bytes memory _data)
external
returns (address plugin, PreparedSetupData memory preparedSetupData)
{
(address manager, uint256 initialNumber) = abi.decode(_data, (address, uint256));
plugin = ProxyLib.deployUUPSProxy(
implementation(),
abi.encodeCall(MyUpgradeablePlugin.initialize, (IDAO(_dao), initialNumber))
);
PermissionLib.MultiTargetPermission[] memory permissions = new PermissionLib.MultiTargetPermission[](2);
// 1) The manager may call setNumber on the plugin.
permissions[0] = PermissionLib.MultiTargetPermission({
operation: PermissionLib.Operation.Grant,
where: plugin,
who: manager,
condition: PermissionLib.NO_CONDITION,
permissionId: MyUpgradeablePlugin(implementation()).MANAGER_PERMISSION_ID()
});
// 2) The plugin may make the DAO execute.
permissions[1] = PermissionLib.MultiTargetPermission({
operation: PermissionLib.Operation.Grant,
where: _dao,
who: plugin,
condition: PermissionLib.NO_CONDITION,
permissionId: DAO(payable(_dao)).EXECUTE_PERMISSION_ID()
});
preparedSetupData.permissions = permissions;
}
function prepareUninstallation(address _dao, SetupPayload calldata _payload)
external
view
returns (PermissionLib.MultiTargetPermission[] memory permissions)
{
address manager = abi.decode(_payload.data, (address));
permissions = new PermissionLib.MultiTargetPermission[](2);
permissions[0] = PermissionLib.MultiTargetPermission({
operation: PermissionLib.Operation.Revoke, where: _payload.plugin, who: manager,
condition: PermissionLib.NO_CONDITION,
permissionId: MyUpgradeablePlugin(implementation()).MANAGER_PERMISSION_ID()
});
permissions[1] = PermissionLib.MultiTargetPermission({
operation: PermissionLib.Operation.Revoke, where: _dao, who: _payload.plugin,
condition: PermissionLib.NO_CONDITION, permissionId: DAO(payable(_dao)).EXECUTE_PERMISSION_ID()
});
}
}The install data layout ((address manager, uint256 initialNumber) here) is your plugin's public contract with installers, publish it in the build metadata, and expose a typed encodeInstallationParameters / decodeInstallationParameters pair on your setup so callers never hand-pack bytes (shown below). Two rules the permission system enforces on this array: a conditional grant must use Operation.GrantWithCondition (a plain Grant carrying a non-zero condition reverts), and uninstall should return the mirror of what install granted, so nothing is stranded.
Typed install data
The install data is positional abi.encoded bytes, and hand-packing them is where installers slip: a wrong field order or type makes prepareInstallation revert on abi.decode (or, worse, silently misconfigure). Publish the ABI as code, a matched pair of pure helpers on your setup, and route prepareInstallation through the decoder so the encode and decode sides can't drift. This is what the Token Voting setup ships, and it's the norm worth copying:
// Add to MyPluginSetup:
function encodeInstallationParameters(address manager, uint256 initialNumber)
external pure returns (bytes memory)
{
return abi.encode(manager, initialNumber);
}
function decodeInstallationParameters(bytes memory _data)
public pure returns (address manager, uint256 initialNumber)
{
(manager, initialNumber) = abi.decode(_data, (address, uint256));
}Then prepareInstallation decodes through the pair instead of a raw abi.decode:
(address manager, uint256 initialNumber) = decodeInstallationParameters(_data);Now the layout lives in exactly one place. Installers build their data with setup.encodeInstallationParameters(manager, n) rather than hand-rolling abi.encode, which is precisely what Launch a governance token does against the Token Voting setup. Keep the pair in lockstep with the build metadata ABI: they describe the same thing, so a change to one is a change to both.
What you just saw
- A plugin is your logic + a setup recipe; the recipe, not the plugin, wires permissions.
authdefers to the DAO (the plugin is thewhere); to make the DAO act, the setup grants the pluginEXECUTEon the DAO.prepareUninstallationmirrorsprepareInstallation's grants as revokes. For an updatable plugin, extendPluginUpgradeableSetupand addprepareUpdate(see Update a plugin).- Publish typed install params. Add an
encodeInstallationParameters/decodeInstallationParameterspair to your setup so installers never hand-packdata, the pattern the Token Voting setup and the launch guide use.
Next
- Publish a plugin to a PluginRepo, turn this setup into an installable, versioned release.
- Write a custom condition if your plugin's permissions need dynamic rules.