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:
- Phase 1 — mine
shares,loot, anddaoShip. These are independent of each other. - Phase 2 — mine
vault, using thedaoShipaddress from phase 1. The vault'sinitCodeHashembeds 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.