Skip to content
DocsDevelopersBuild a Navigator

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 DAOShip permission bit via a governance setNavigators proposal. 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.navigators Poster list that names it. Exemplar: Signal.
  • Module — takes no DAOShip permission. Instead it is enabled as a Zodiac module on the Quai Vault and acts on the treasury via execTransactionFromModule. 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:

BitPermissionPowers
1ADMINPause/unpause tokens (setAdminConfig)
2MANAGERMint and burn shares and loot
4GOVERNORCancel 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. Expose claimed(tokenId) plus a canOnboard(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 / balanceOf in try/catch and resolves failures to false, so a hostile or non-conforming collection can't make onboarding revert or mis-mint. Because the collection may be mintable, its mintCap is 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:

  1. Deploy your navigator with _daoShip set to the target DAO and sensible caps/expiry/bounds.
  2. The constructor emits NavigatorDeployed; the indexer registers it in ds_navigators with a trust_status (self_asserted until endorsed). This is true for all three classes — see Indexer.
  3. Endorse it, by class:
    • Permissioned — submit a proposal calling setNavigators. For a MANAGER navigator, build setNavigators([myNavigator], [2]) calldata, wrap it in MultiSend, and submit it.
    • Read-only — submit a proposal that has the vault post a daoships.dao.navigators list naming the navigator. Until then its data stays hidden (self_asserted).
    • Module — submit a proposal calling enableModule(myNavigator) on the Quai Vault.
  4. Once the proposal passes and processes, the navigator becomes sanctioned and 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.