Skip to content
DocsDevelopersLaunch from TypeScript

Launch a DAO from TypeScript

This guide walks through deploying a complete DAO — a Quai Vault treasury, a DAOShip governance module, and the SharesERC20 / LootERC20 tokens — in a single transaction using the quais SDK. Then you submit a proposal and attach metadata via Poster.

The addresses in the snippets below target Quai mainnet (Cyprus-1), chain ID 9. The same code runs against the Orchard testnet (chain ID 15000) — swap in the testnet column from Contracts, which is the canonical source for both networks.

1. Encode the governance config

Governance parameters are ABI-encoded into a single bytes blob. All percentages are basis points (10000 = 100%).

import { quais } from "quais";
 
const governanceConfig = quais.AbiCoder.defaultAbiCoder().encode(
  ["uint32", "uint32", "uint256", "uint256", "uint256", "uint256", "uint32"],
  [
    7 * 24 * 3600,          // votingPeriod: 7 days
    3 * 24 * 3600,          // gracePeriod: 3 days
    quais.parseQuai("0.1"), // proposalOffering
    2000,                   // quorumPercent: 20% (basis points)
    quais.parseQuai("1"),   // sponsorThreshold: 1 share
    6600,                   // minRetentionPercent: 66%
    7 * 24 * 3600,          // defaultExpiryWindow: 7 days
  ],
);

All seven fields are required

DAOShip decodes this blob as a seven-field tuple, and the trailing defaultExpiryWindow is a uint32, not a uint256. Omit it or mistype it and abi.decode reverts during DAO initialization — the launch fails with no clearer signal than a failed transaction. See Governance parameters for per-template values.

Basis points, not percentages

Pass 2000 for 20%, 10000 for 100%. Passing a raw percentage like 20 creates a near-zero threshold — a critical misconfiguration.

2. Encode the init params template

launchDAOShipAndVault takes an init-params template. You set the avatar (3rd field) to a placeholder — the launcher overwrites it with the real vault address before launch. The token addresses are likewise filled in by the launcher.

const MULTISEND = "0x003f62e6a7f2EB6b94345a9A41671888eC4A3ebA"; // MultiSendCallOnly (mainnet)
const founder = "0xYourFounderAddress";
 
const initParamsTemplate = quais.AbiCoder.defaultAbiCoder().encode(
  ["address","address","address","address","bytes","address[]","uint256[]","address[]","uint256[]","uint256[]","address[]","bool","bool"],
  [
    quais.ZeroAddress,         // lootToken   (filled by launcher)
    quais.ZeroAddress,         // sharesToken (filled by launcher)
    quais.ZeroAddress,         // avatar      (placeholder -> vault)
    MULTISEND,                 // multisend library
    governanceConfig,
    [],                        // navigators
    [],                        // navigator permissions
    [founder],                 // initial members
    [quais.parseQuai("100")],  // initial shares
    [quais.parseQuai("0")],    // initial loot
    [],                        // guild tokens
    false,                     // pauseSharesOnLaunch
    false,                     // pauseLootOnLaunch
  ],
);

3. Mine CREATE2 salts for the 0x00 shard

Cyprus-1 requires every contract address to begin with the 0x00 shard prefix. Because the clones are deployed via CREATE2, you mine salts off-chain until calculateAllAddresses returns four addresses that all start with 0x00.

minExecutionDelay must be 0 here — launchDAOShipAndVault hardcodes 0, so any other value produces a vault prediction that will not match the deployed address.

`sender` is the launcher address, not your wallet

calculateAllAddresses(sender, …) forwards sender to DAOShipLauncher.calculateAddresses, which predicts clone addresses from keccak256(abi.encodePacked(sender, salt)). At deploy time the caller of DAOShipLauncher is DAOShipAndVaultLauncher — not you — so the value that actually gets hashed is the launcher's own address:

// DAOShipAndVaultLauncher.launchDAOShipAndVault
(address predictedDAOShip, , ) = daoShipLauncher.calculateAddresses(
    address(this), sharesSalt, lootSalt, daoShipSalt
);

Pass the DAOShipAndVaultLauncher address. Passing your EOA is the intuitive reading of the parameter name and it is wrong: you will mine four addresses that all begin with 0x00, then deploy to four entirely different addresses that probably do not — and the launch reverts.

const LAUNCHER = "0x0067b50Dac689d8688eF8575B82Bc663802f3AF5"; // DAOShipAndVaultLauncher (mainnet)
 
const launcher = new quais.Contract(
  LAUNCHER,
  DAOSHIP_AND_VAULT_LAUNCHER_ABI,
  signer,
);
 
const vaultOwners = [founder];
const vaultThreshold = 1;
 
function startsWith00(addr: string): boolean {
  return addr.toLowerCase().startsWith("0x00");
}
 
// `sender` must be LAUNCHER — never the founder EOA. See the callout above.
async function mineSalts(sender: string) {
  for (let i = 0; ; i++) {
    const sharesSalt = BigInt(i) * 4n + 0n;
    const lootSalt = BigInt(i) * 4n + 1n;
    const daoShipSalt = BigInt(i) * 4n + 2n;
    const vaultSalt = BigInt(i) * 4n + 3n;
 
    const [daoShip, shares, loot, vault] = await launcher.calculateAllAddresses(
      sender, sharesSalt, lootSalt, daoShipSalt, vaultSalt,
      vaultOwners, vaultThreshold, 0, // minExecutionDelay must be 0
    );
 
    if ([daoShip, shares, loot, vault].every(startsWith00)) {
      return { sharesSalt, lootSalt, daoShipSalt, vaultSalt, daoShip, shares, loot, vault };
    }
  }
}

Mine in a worker

The live app runs salt mining in a Web Worker so the UI stays responsive. For server scripts, prefer a local prediction routine over an RPC round-trip per attempt when you have the singleton addresses and creation bytecode.

Predicting locally? Mining is two-phase, and the two factories pack salts differently

The loop above is single-pass because calculateAllAddresses resolves the ordering on-chain for you — it predicts DAOShip first, then feeds that address into the vault prediction as initialModules. A local reimplementation does not get that for free:

  1. Phase 1 — mine shares, loot, and daoShip. These are independent of each other.
  2. Phase 2 — mine vault, using the daoShip address from phase 1. The vault's initCodeHash embeds it, so the two phases cannot be collapsed.

The two factories declare their salt parameter differently — QuaiVaultFactory takes a bytes32, DAOShipLauncher a uint256 — but that distinction never reaches the hash. abi.encodePacked renders both as the same 32 big-endian bytes, so keccak256(abi.encodePacked(address, bytes32)) and keccak256(abi.encodePacked(address, uint256)) are identical for the same value. One implementation covers both factories; there is nothing to branch on. Do pad short salts to a full 32 bytes, though — a short hex string packed as bytes32 is a different preimage.

The vault is a QuaiVaultProxy, not an ERC-1167 minimal proxy like the other three, so its initCodeHash is keccak256(QuaiVaultProxy.bytecode + abi.encode(["address","bytes"], [implementation, initData])).

Mine naively, then verify the result with a single calculateAllAddresses call rather than trusting a local reimplementation of the vault hash.

4. Launch

Pass the mined salts to launchDAOShipAndVault. The sender you mined against must be the DAOShipAndVaultLauncher address — it is msg.sender to both factories at deploy time — or the predicted addresses will not match what gets deployed.

const salts = await mineSalts(LAUNCHER); // NOT await signer.getAddress()
 
const tx = await launcher.launchDAOShipAndVault(
  initParamsTemplate,
  "MyDAO Shares", "MDS",
  "MyDAO Loot", "MDL",
  vaultOwners, vaultThreshold,
  salts.vaultSalt, salts.sharesSalt, salts.lootSalt, salts.daoShipSalt,
);
const receipt = await tx.wait();
// Event: LaunchDAOShipAndVault(daoShip, vault, shares, loot, newVault, launcher)

After this transaction, DAOShip is already enabled as a module on the vault — there is no separate enableModule step.

5. Submit a proposal

Proposals carry a MultiSend-encoded batch of vault actions. Here we send 10 QUAI from the treasury:

const daoShip = new quais.Contract(salts.daoShip, DAOSHIP_ABI, signer);
 
const proposalData = encodeMultiSend([{
  operation: 0,                 // Call
  to: recipientAddress,
  value: quais.parseQuai("10"),
  data: "0x",
}]);
 
await daoShip.submitProposal(proposalData, 0, "Fund community event", {
  value: await daoShip.proposalOffering(),
});

6. Post metadata via Poster

Poster stores no state — it emits NewPost for indexers and frontends. Attach a DAO profile under the recognized tag so the indexer picks it up:

const poster = new quais.Contract(
  "0x004Db03AA2593B4885AFEFF688ca2634D1533fac", // Poster (mainnet)
  [
    "function post(string content, string tag) external",
    "event NewPost(address indexed user, string content, string indexed tag)",
  ],
  signer,
);
 
await poster.post(
  JSON.stringify({ name: "My DAO", description: "A test ship", logo: "ipfs://Qm..." }),
  "daoships.launcher.daoProfile",
);

The indexer stores these posts in ds_records, keyed by tag and trust level. See Indexer for how to query them, and Build a Navigator to add onboarding extensions.