Build a Navigator
A navigator is an immutable extension contract that adds behavior to a ship without changing the
immutable DAOShip core — onboarding members, vesting tokens, running polls, paying budgets. This
guide shows how to write one safely.
Navigators come in three classes, and the class decides how the DAO endorses your contract and whether the indexer surfaces it. Eight navigators ship today as exemplars: Onboarder, ERC-20 Tribute, NFT-Gate, Timelock, Vesting, Subscription (permissioned), Signal (read-only), and Budget (module). All extend the same patterns; pick the one closest to what you're building and copy it. Start by skimming the Navigators overview for the catalog.
Three classes of navigator
A navigator's power lives somewhere — on DAOShip, in social trust, or on the Quai Vault — and it is
endorsed where that power lives. This mirrors the Navigators overview; the
short version:
- Permissioned — granted a
DAOShippermission bit via a governancesetNavigatorsproposal. It can mint, burn, pause, or change governance config. Exemplars: Onboarder, ERC-20 Tribute, NFT-Gate, Timelock, Vesting, Subscription. - Read-only — takes no permission and never mutates the DAO; it only reads state. It is
endorsed socially, by the DAO's vault posting a
daoships.dao.navigatorsPoster list that names it. Exemplar: Signal. - Module — takes no
DAOShippermission. Instead it is enabled as a Zodiac module on the Quai Vault and acts on the treasury viaexecTransactionFromModule. Exemplar: Budget.
Whichever class you build, the constructor still emits NavigatorDeployed and the indexer still
discovers you (more on that below).
The INavigator interface
Every navigator implements INavigator. It exposes its deployer, a compile-time navigatorType, and
emits NavigatorDeployed exactly once, in the constructor:
interface INavigator {
event NavigatorDeployed(
address indexed daoShip,
address indexed deployer,
string navigatorType,
string name,
string description
);
function deployer() external view returns (address);
function navigatorType() external view returns (string memory);
}Immutable, never proxied
Navigators use immutable state and MUST NOT sit behind a proxy. Because NavigatorDeployed is
emitted in the constructor, only the deployer can author the name and description — no spoofing or
metadata poisoning is possible. The indexer reads this event for navigator discovery. To change config,
deploy a new instance and re-endorse it — never upgrade in place.
Extend BaseNavigator
BaseNavigator (abstract, inherits ReentrancyGuard and INavigator) is the base for the
minting navigators (Onboarder, ERC-20 Tribute, NFT-Gate). It gives you the shared machinery:
allowlist verification, mint-cap accounting, pause/unpause, and a minting helper. Read-only and some
permissioned navigators (Signal, Vesting, Timelock) are standalone — see those sections below. Its
constructor wires up the bounded-trust immutables:
constructor(
address _daoShip,
uint256 _expiry, // 0 = no expiry
uint256 _mintCap, // 0 = unlimited total shares+loot
uint256 _perAddressCap, // 0 = unlimited per recipient
bytes32 _allowlistRoot // bytes32(0) = open to anyone
)A concrete navigator must define a string public constant navigatorType, emit NavigatorDeployed in
its own constructor, and implement its pricing or tribute logic. Here is the minimal shape:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;
import "./BaseNavigator.sol";
contract MyNavigator is BaseNavigator {
string public constant navigatorType = "MyNavigator";
constructor(
address _daoShip,
uint256 _expiry,
uint256 _mintCap,
uint256 _perAddressCap,
bytes32 _allowlistRoot,
string memory _name,
string memory _description
) BaseNavigator(_daoShip, _expiry, _mintCap, _perAddressCap, _allowlistRoot) {
emit NavigatorDeployed(_daoShip, msg.sender, navigatorType, _name, _description);
}
function onboard(bytes32[] calldata proof) external payable nonReentrant {
if (paused) revert IsPaused();
if (expiry != 0 && block.timestamp > expiry) revert Expired();
_checkAllowlist(proof);
uint256 sharesToMint = /* your pricing */;
uint256 lootToMint = /* your pricing */;
uint256 toMint = sharesToMint + lootToMint;
if (toMint == 0) revert InsufficientTribute();
_checkAndUpdateCaps(toMint); // enforces mintCap + perAddressCap
_mintSharesAndLoot(msg.sender, sharesToMint, lootToMint);
(bool ok, ) = daoShip.avatar().call{value: msg.value}(""); // tribute to treasury
if (!ok) revert TransferFailed();
emit Onboard(address(daoShip), msg.sender, msg.value, sharesToMint, lootToMint);
}
}The permission bitmask
DAOShip grants navigators an additive permission bitmask. Check it with a bitwise AND against the
address's current grant:
| Bit | Permission | Powers |
|---|---|---|
1 | ADMIN | Pause/unpause tokens (setAdminConfig) |
2 | MANAGER | Mint and burn shares and loot |
4 | GOVERNOR | Cancel proposals, set governance config |
Combinations are sums: 3 is ADMIN+MANAGER, 6 is MANAGER+GOVERNOR, 7 is full access. To call
mintShares / mintLoot your navigator needs MANAGER. In BaseNavigator, pause and unpause are
gated on GOVERNOR or the avatar:
function pause() external {
if ((daoShip.navigators(msg.sender) & _GOVERNOR) == 0 && msg.sender != daoShip.avatar())
revert NotAuthorized();
paused = true;
emit Paused(msg.sender);
}MANAGER is dilution power
A MANAGER navigator can mint unbounded shares and loot — directly diluting every member. Always
bound it with a mintCap, a perAddressCap, and an expiry, keep it immutable, and never grant more
permission than the navigator needs.
Beyond MANAGER: the other permissions
MANAGER is the most common bit, but two others exist — and they behave differently.
GOVERNOR (4) navigators change governance config or cancel proposals. The shipped
Timelock is the exemplar: a governance proposal calls
timelock.queueChange(governanceConfig) instead of daoShip.setGovernanceConfig directly, and the
change can only be forwarded with executeChange after a delay elapses — giving members a second
ragequit window.
A GOVERNOR gate is advisory, not enforced
A GOVERNOR navigator cannot make itself mandatory at the contract layer. A governance proposal can
always call the same DAOShip governor function directly via executeAsGovernance (which runs the
inner call as msg.sender == address(daoShip)), bypassing your navigator entirely. lockGovernor()
does not close that path — governance reaches governor functions through executeAsGovernance
regardless of locks. So design GOVERNOR navigators as tooling plus on-chain signals — emit clear
events the Indexer can flag (e.g. warn on a proposal that calls
setGovernanceConfig directly on a timelock-enabled DAO) — not as hard on-chain enforcement.
ADMIN (1) navigators pause and unpause token transfers via setAdminConfig. This is the planned
Circuit Breaker pattern: monitor on-chain conditions and auto-pause Shares and Loot on anomalies
(mint spikes, treasury drains), with unpause reserved for human governance.
Read-only navigators (no permission)
A read-only navigator only reads DAO state — it never mutates the DAO, so it needs no permission
bit and no ReentrancyGuard (there are no value transfers). Signal is the
exemplar: it reads share-weighted, delegation-aware voting power via
daoShip.getPriorVotes(voter, timepoint) to run non-binding polls. A bug cannot mint, burn, or pause —
the worst case is a mis-tallied, non-binding poll.
Because it is unpermissioned, anyone can deploy one pointed at any DAO. The DAO association in its
NavigatorDeployed event is therefore self-asserted. The indexer marks it self_asserted and does
not surface its data until the DAO sanctions it — a governance action where the DAO's vault
posts a daoships.dao.navigators list naming the navigator address. That post is authenticated
(msg.sender == vault), grants zero permission, and changes only how the navigator is displayed, not
what it can do.
The design implication: a read-only navigator must be deployed after the DAO exists (it cannot be wired up in the launch wizard — the DAO isn't on the indexer yet), and your UI or tooling should drive the sanction post from the DAO's Navigators page. See the three trust classes for how the indexer treats each status.
Module navigators (vault-enabled)
A module navigator takes no DAOShip permission. Instead it is enabled as a Zodiac module on the
Quai Vault and calls IAvatar(vault).execTransactionFromModule(to, value, data, operation) to move
treasury funds — it never mints. The shipped Budget navigator is the
exemplar: governance approves a recurring spend (manager, token, per-period allowance, lifetime
ceiling) and a delegated manager disburses without a proposal per payment. See
the Quai Vault treasury for how vault custody works.
Endorsement is a governance proposal that calls enableModule(navigator) on the vault — there is no
setNavigators call. The indexer derives trust from the vault's EnabledModule / DisabledModule
events. A deployed-but-not-enabled module navigator is powerless and hidden.
A vault module can move funds
The vault enforces no per-module limit, so a module navigator's own caps are the treasury's only
on-chain guarantee. Make it immutable, bounded (e.g. a per-period allowance plus a lifetime
ceiling), and have it mirror the audited treasury-transfer path (hardcode Operation.Call — no
DelegateCall) rather than inventing its own. A compromised manager is then bounded to its budget; only a
bug in your contract risks the whole treasury — hence the minimal surface and a treasury-grade audit.
Gating membership — IMembershipGate
Not every navigator prices tribute; some gate on a condition — owning an NFT, being on a list,
holding another token. The shipped NFT-Gate navigator is the reference: it
onboards holders of an ERC-721 collection and implements IMembershipGate, so its eligibility check is
reusable by other contracts.
interface IMembershipGate {
function isEligible(address candidate) external view returns (bool); // e.g. balanceOf > 0
function isEligibleToken(address candidate, uint256 tokenId) external view returns (bool);
}Two patterns worth copying from it:
- Per-token "claim ticket" accounting. It tracks
claimed[tokenId], not claims per address, so moving the NFT to a fresh wallet can't recycle a second claim, and minted Shares persist even after the NFT is sold. Exposeclaimed(tokenId)plus acanOnboard(candidate, tokenId)preflight view so frontends can show what's still claimable without sending a transaction. - Treat the gate as untrusted. It wraps every
ownerOf/balanceOfintry/catchand resolves failures tofalse, so a hostile or non-conforming collection can't make onboarding revert or mis-mint. Because the collection may be mintable, itsmintCapis mandatory (a zero cap is rejected in the constructor).
On a successful claim it emits both the generic Onboard event and a navigator-specific
NFTClaimed(daoShip, holder, tokenId, shares, loot) — see the Indexer page
for how to consume them without double-counting.
Governance enables a permissioned navigator
A permissioned navigator has no power until governance grants it permission. That happens through a
passed proposal calling setNavigators, which is governanceOnly — it requires
msg.sender == address(this), so it can only execute via the proposal queue:
function setNavigators(address[] calldata navigators, uint256[] calldata permissions)
external governanceOnly;setNavigators rejects permission bits above MAX_PERMISSION (7) and honors any active locks: if
lockManager() has been called, the call reverts with ManagerLocked when it tries to grant MANAGER
to a new navigator (the same applies to lockAdmin / lockGovernor). These locks are irreversible —
they freeze the roster but never revoke an existing navigator, and governance can always still call the
locked functions directly via executeAsGovernance.
Deploy and enable workflow
The deploy step is the same for every class — but the endorsement step differs by class:
- Deploy your navigator with
_daoShipset to the target DAO and sensible caps/expiry/bounds. - The constructor emits
NavigatorDeployed; the indexer registers it inds_navigatorswith atrust_status(self_asserteduntil endorsed). This is true for all three classes — see Indexer. - Endorse it, by class:
- Permissioned — submit a proposal calling
setNavigators. For a MANAGER navigator, buildsetNavigators([myNavigator], [2])calldata, wrap it in MultiSend, and submit it. - Read-only — submit a proposal that has the vault post a
daoships.dao.navigatorslist naming the navigator. Until then its data stays hidden (self_asserted). - Module — submit a proposal calling
enableModule(myNavigator)on the Quai Vault.
- Permissioned — submit a proposal calling
- Once the proposal passes and processes, the navigator becomes
sanctionedand can act.
See scripts/replace-navigator.ts in the contracts repo for a complete deploy-to-proposal encoding
pattern for the permissioned case. For the surrounding system, read
Contracts and the Navigators overview; to
watch deploy and onboard events, see Indexer.