The Indexer
Reading DAO state directly from the chain is slow and awkward — voting power lives in timestamped checkpoints, proposals span multiple events, and member balances derive from transfers. The DAO Ships indexer solves this by consuming on-chain events and writing structured rows into a Supabase (PostgreSQL) database your frontend can query directly.
The indexer is event-driven: every row derives from on-chain events, with zero RPC calls in
its handlers. Counter operations are idempotent (safe to retry under reorg or replay), and it
indexes more than two dozen distinct event types across the launcher, governance, tokens, the
full navigator suite, and Poster.
Connecting
Endpoint https://anpmmwvxzchumfclhvmr.supabase.co
Key sb_publishable_BdCkzZNKGhfs1AJUWFsgWw_yh3OhLi2That key is publishable and published on purpose: the database is read-only at the RLS level and
holds only data that is already public on-chain, so it is the same class of value as a Firebase
web config. It is the key for AI agents, SDK consumers, and third-party integrations — the web
client uses a different one, so that agent traffic and application traffic have separate quotas.
Every example below uses these two values as SUPABASE_URL and SUPABASE_ANON_KEY.
Schema per network
Each network is an isolated PostgreSQL schema — mainnet for the Quai mainnet deployment,
testnet for Orchard — and every table is prefixed ds_. Point your client at the schema for the network you're reading (see the
query examples below).
The schema selector is not optional. PostgREST's default schema is public, which contains no
ds_* tables, so a request without one fails loudly with PGRST205: Could not find the table 'public.ds_daos'. The dangerous mistake is the quiet one: naming the wrong network returns real,
well-formed rows from the other chain.
Core tables
These tables are always present. The frontend-relevant columns are listed for each — see the Frontend integration page for full row types.
| Table | Purpose | Key frontend columns |
|---|---|---|
ds_daos | DAO records — governance params, token totals, profile | id (address), avatar, shares_address, loot_address, total_shares, total_loot, active_member_count, proposal_count, voting_period, grace_period, quorum_percent, sponsor_threshold, min_retention_percent, name, description, avatar_img, profile_source |
ds_members | Member share/loot balances, delegation, vote counts | id, dao_id, member_address, shares, loot, delegating_to, voting_power, votes, last_activity_at |
ds_proposals | Proposal lifecycle (submit, sponsor, vote, process, cancel) | id, dao_id, proposal_id, submitter, sponsor, yes_balance, no_balance, yes_votes, no_votes, voting_starts, voting_ends, grace_ends, expiration, cancelled, processed, passed, sponsored, details, proposal_data |
ds_votes | Individual vote records | proposal_id, voter, approved, balance, block_number |
ds_navigators | Registered navigators with type, permission, and trust | see Navigator discovery |
ds_navigator_events | Generic onboard events emitted by navigators | dao_id, navigator_address, event_type (onboard), contributor, shares_minted, loot_minted, amount |
ds_ragequits | Member exit records with per-token amounts | dao_id, member_address, to_address, shares_burned, loot_burned, tokens[], amounts[] |
ds_records | Poster metadata (profiles, rationale, announcements) | dao_id (nullable), user_address, tag, content_type, content (RAW/UNTRUSTED), content_json (validated), trust_level, block_number |
ds_guild_tokens | Registered ragequit tokens | dao_id, token_address, enabled |
ds_delegations | Delegation change history | dao_id, delegator, from_delegate, to_delegate, tx_hash |
ds_event_transactions / ds_processed_logs | Dedup tracking for retry idempotency | (internal) |
ds_indexer_state | Last processed block and sync state | last_block_number, is_syncing, requires_full_reindex, reindex_reason, chain_id |
Per-navigator tables
Each navigator type writes to its own dedicated tables in addition to the generic
ds_navigator_events feed. Every table carries navigator_address (and usually dao_id) so you
can scope queries to one navigator or one DAO.
NFT-Gate
Backs the NFT-gate navigator.
| Table | Key columns |
|---|---|
ds_nft_claims | id (= navigatorAddress-tokenId), dao_id, navigator_address, token_id, holder (original claimer), shares, loot |
A token can be claimed exactly once, ever, so a frontend can answer "is token #N claimed?" in O(1)
by reading the row with id = navigatorAddress-tokenId — a present row means claimed, with
holder the original claimer. The claim also writes a generic event_type='onboard' row in
ds_navigator_events; the two are complementary, not duplicates, so don't sum both when tallying
minted amounts.
Signal
Backs the Signal navigator. Rows are materialized only when the
navigator's trust_status='sanctioned' (see below).
| Table | Key columns |
|---|---|
ds_signal_polls | navigator_address, poll_id, creator, question, option_count, snapshot_timestamp, voting_starts, voting_ends, cancelled, tally (NUMERIC[], derived), options (off-chain labels) |
ds_signal_votes | poll_id, voter, option, weight (snapshot shares) |
tally is the indexer's authoritative per-option result (derived from the vote rows); options
holds off-chain human labels and is null until the creator's labels post is indexed — render
numeric Option 1..n as a fallback. weight is share power at the snapshot and excludes loot.
Timelock
Backs the Timelock navigator. Permissioned, so always sanctioned.
| Table | Key columns |
|---|---|
ds_timelock_changes | navigator_address, change_id, queued_by, config_hash, governance_config (FULL bytes), executable_after, expires_at, status (queued / executed / cancelled) |
ds_governance_config_history | dao_id, the 7 config fields, bypassed_timelock (boolean) |
governance_config stores the full ABI-encoded bytes so anyone can crank executeChange — on-chain
only the hash is kept. bypassed_timelock=true means a config change applied directly while a
timelock was active (it skipped the delay) — surface a warning.
Vesting
Backs the Vesting navigator. Permissioned, so always sanctioned.
| Table | Key columns |
|---|---|
ds_vesting_schedules | navigator_address, schedule_id, beneficiary, total_amount, claimed (SUM of claims), is_loot, start_time, cliff_end, vesting_end, revoked, revoked_at |
ds_vesting_claims | incremental amount per claim |
claimed is derived from the claim rows. Vesting is not balance — member voting and economic weight
come from ds_members (fed by the paired token Transfer on each claim), never from these rows.
Budget
Backs the Budget navigator. The module trust class — gate every query
on trust_status='sanctioned'.
| Table | Key columns |
|---|---|
ds_budgets | navigator_address, budget_id, manager, token (0x0 = native), allowance_per_period, total_ceiling, total_spent (SUM), period_length, starts_at, ends_at, cancelled |
ds_budget_disbursements | recipient, token, amount (one row per recipient) |
ds_vault_module_events | vault, navigator_address, enabled (boolean) — the Budget trust feed |
total_spent is the derived lifetime SUM; for an exact live "remaining this period" figure read the
contract views rather than the stored cumulative. Treasury balances come from the paired vault
transfer, not from these rows — don't double-count.
Subscription
Backs the Subscription navigator. Permissioned, so always sanctioned.
| Table | Key columns |
|---|---|
ds_subscription_members | member, paid_through (absolute unix secs), total_paid (SUM), last_collected_at |
ds_subscription_payments | member, payer, token, amount, periods, paid_through |
ds_subscription_collections | member, collector, shares_removed, reward, burned (boolean) |
Membership is one row per member per navigator. paid_through is the whole enrollment state — the
absolute timestamp the member is paid up through, 0 meaning not enrolled. total_paid is the
derived SUM of payment amounts.
Navigator discovery and the three trust classes
Every navigator announces itself with a NavigatorDeployed event and is recorded in ds_navigators
against the DAO it names. Because anyone can deploy a contract claiming any DAO, that binding is not
automatically trustworthy — ds_navigators carries the columns you need to decide.
| Column | Meaning |
|---|---|
dao_id | The DAO this navigator names (bound at deploy for every navigator) |
navigator_address | The navigator contract |
navigator_type | OnboarderNavigator, ERC20TributeNavigator, NFTGatedNavigator, SignalNavigator, TimelockNavigator, VestingNavigator, BudgetNavigator, SubscriptionNavigator |
permission | Permission bitmask (0 = none) |
permission_label | Human label for the bitmask (none, admin, manager, governor, all, etc.) |
permission_ever_granted | TRUE once a NavigatorSet with permission > 0 was seen — distinguishes a REVOKED nav from a born read-only one |
trust_status | self_asserted, sanctioned, unsanctioned, or fabricated |
is_active | "Functional now?" — NOT "has permission" |
paused | Whether the navigator is paused |
deployer, name, description, deploy_block, allowlist_root, config (JSONB) | Deploy-time metadata |
There are three trust classes, distinguished by how a navigator earns its sanction:
- Permissioned (Onboarder, ERC20Tribute, NFT-Gate, Timelock, Vesting, Subscription) — discovered
and sanctioned via
NavigatorSet. The DAO granting a permission bit is the vouch, so these are bornsanctioned. - Read-only (Signal) — holds no permission. Sanctioned via a vault
daoships.dao.navigatorsPosterpost; its poll history is backfilled on sanction. - Module (Budget) — authority is being an enabled Zodiac module on the vault. Sanctioned via a
vault
EnabledModuleevent (the indexer derives trust fromds_vault_module_events).
Trust-gate read-only and module navigators
A read-only (Signal) or module (Budget) navigator's data is materialized only when
trust_status='sanctioned'. A self_asserted navigator looks identical on-chain whether the DAO
endorsed it or not, so a frontend MUST filter on trust_status='sanctioned' before showing its
polls or budgets. "No rows" for a self_asserted navigator is expected, not a bug. Permissioned
navigators are always sanctioned, so this gating affects only the Signal and Budget classes. See the
Navigators overview.
is_active reflects functionality, not permission: a read-only navigator stays is_active=true at
permission=0, a module navigator is is_active=false until the vault enables it, and a permissioned
navigator is inert until granted. Filtering is_active=true is still the right "show usable
navigators" filter.
Status is time-derived, totals are derive-from-truth
Lifecycle status is not stored as a single column. For proposals, signal polls, timelock changes,
vesting schedules, budgets, and subscription members you compute status client-side from the
timestamp columns. Use the pre-calculated boundaries the indexer already stores — voting_starts,
voting_ends, grace_ends, expiration, executable_after, expires_at, cliff_end,
vesting_end, paid_through — rather than doing the period arithmetic yourself.
Aggregate totals are recomputed from their source rows on every change, not incremented in place.
total_shares (from member balances), poll tally (from vote rows), vesting claimed (from claim
rows), budget total_spent (from disbursement rows), and subscription total_paid (from payment
rows) are all derive-from-truth, which makes them replay- and reorg-safe.
NUMERIC(78,0): cast to text, or lose the value
Token amounts and voting power are uint256 on-chain. In Postgres they are stored as
NUMERIC(78, 0) — large enough to hold any uint256. PostgREST serialises them as bare JSON
numbers, so by the time JSON.parse has run, the value is already a double and the damage is
done. This is a live response from the mainnet indexer:
[{"id":"0x001117dd…","name":"The First DAO Ships DAO","total_shares":3000000000000000000000}]There is no safe coercion after the fact. Both of the obvious repairs fail:
const v = JSON.parse(body)[0].total_shares; // 3e+21, a Number
BigInt(v); // may be silently WRONG — 1234567890123456789012 → …774144 (off by 14868)
BigInt(String(v)); // THROWS: "Cannot convert 3e+21 to a BigInt"Anything at or above 1e21 stringifies to exponential notation, which BigInt() rejects
outright. Below that it converts, but to whatever value the double rounded to. A wrong balance
and a thrown exception are both bad; the wrong balance is worse, because it looks like an answer.
Cast in the query, not in your code
Append ::text to every large numeric column in the select. The value then arrives as a string
and never becomes a Number at all.
// supabase-js
const { data } = await supabase
.from("ds_daos")
.select("id, total_shares::text, total_loot::text")
.eq("id", daoId);
// raw PostgREST
// GET /rest/v1/ds_daos?select=id,total_shares::text,total_loot::text&id=eq.0x…
BigInt(data[0].total_shares); // safe — it was never a doubleIf you cannot control every select — a generic query layer, say — rewrite the raw response body
before parsing it, quoting only those integer literals that cannot round-trip through a double
and leaving the rest byte-identical. Track string state while scanning so digits inside names,
descriptions, and hashes are never touched. The web client does exactly this as a fetch wrapper
on the Supabase client, because it cannot annotate every call site.
Smaller integer columns (voting_period, proposal_count, yes_votes, block_number) are
BIGINT and are safe as numbers within typical ranges. Small NUMERIC values (a poll_id,
change_id, or schedule_id) arrive as JSON numbers too — those are fine to read directly, but
if you are going to do arithmetic on them, cast them the same way rather than reasoning about
which columns happen to be small today.
Querying with supabase-js
Point the client at the right schema for the network you want:
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
db: { schema: "mainnet" }, // or "testnet" for Orchard
});
// List DAOs, newest first
const { data: daos } = await supabase
.from("ds_daos")
.select("id, name, total_shares, total_loot, proposal_count, voting_period")
.order("updated_at", { ascending: false })
.limit(20);
// Active proposals for one DAO
const { data: proposals } = await supabase
.from("ds_proposals")
.select("proposal_id, details, sponsored, processed, yes_balance, no_balance, voting_ends")
.eq("dao_id", daoAddress)
.order("proposal_id", { ascending: false });
// A member's standing
const { data: member } = await supabase
.from("ds_members")
.select("shares, loot, voting_power, votes, delegating_to")
.eq("dao_id", daoAddress)
.eq("member_address", wallet)
.single();ds_records holds Poster metadata. Filter by tag to fetch DAO profiles:
const { data: profile } = await supabase
.from("ds_records")
.select("content_json, trust_level, created_at")
.eq("dao_id", daoAddress)
.eq("tag", "daoships.launcher.daoProfile")
.order("created_at", { ascending: false })
.limit(1);Treat Poster content as untrusted
ds_records.content is raw on-chain data and is UNTRUSTED — escape it before rendering. Prefer
the sanitized content_json column, and respect the trust_level field (VERIFIED,
VERIFIED_INITIAL, SEMI_TRUSTED, MEMBER).
Realtime subscriptions
ds_daos, ds_proposals, ds_members, ds_votes, ds_records, ds_navigators,
ds_navigator_events, ds_nft_claims, ds_signal_polls, ds_signal_votes, ds_timelock_changes,
ds_governance_config_history, ds_vesting_schedules, ds_budgets, ds_subscription_members, and
ds_indexer_state are published to Supabase Realtime. Subscribe to live-update a vote tally:
const channel = supabase
.channel("votes")
.on(
"postgres_changes",
{ event: "INSERT", schema: "mainnet", table: "ds_votes", filter: `dao_id=eq.${daoAddress}` },
(payload) => {
console.log("new vote", payload.new);
},
)
.subscribe();High-volume append-only feeds (ds_vesting_claims, ds_subscription_payments,
ds_subscription_collections) are intentionally not published. Subscribe to the parent row instead —
its derived totals and updated_at change on every claim/payment — and re-read the feed on demand.
Querying over PostgREST directly
supabase-js is a thin wrapper over PostgREST. You can hit the REST endpoint directly — set the
Accept-Profile header to choose the schema:
curl "$SUPABASE_URL/rest/v1/ds_daos?select=id,name,total_shares::text&order=updated_at.desc&limit=10" \
-H "apikey: $SUPABASE_ANON_KEY" \
-H "Authorization: Bearer $SUPABASE_ANON_KEY" \
-H "Accept-Profile: mainnet"Every list response is a bounded window
PostgREST enforces a server-side maximum row count regardless of what you ask for, and the
failure is silent: you get 200 OK and a well-formed array, just not all of it. Past the cap,
rows do not paginate — as far as your query is concerned they do not exist. A DAO's proposal
list that quietly stops at the ceiling looks exactly like a DAO with fewer proposals.
Ask for the true total rather than inferring it from what came back. Prefer: count=exact
returns it after the slash in Content-Range:
curl -sD - -o /dev/null \
"$SUPABASE_URL/rest/v1/ds_records?select=id" \
-H "apikey: $SUPABASE_ANON_KEY" \
-H "Accept-Profile: mainnet" \
-H "Prefer: count=exact" -H "Range: 0-0"
# content-range: 0-0/3 ← 3 rows exist upstreamThen walk pages with Range (or .range(from, to) in supabase-js) until you have them all, and
keep your own ceiling so a pathological table cannot hang the caller. If you stop early, say so
out loud — surfacing a truncated list as if it were complete is the bug this prevents.
supabase-js query builders are single-use
Build a fresh builder for each page. Reusing one across .range() calls silently returns the
same page every time, which reads as "the table ended" after page one.
Operational signals
The indexer health surface lives in ds_indexer_state. When requires_full_reindex is true
(with reindex_reason explaining why), show a non-blocking "data may be stale" banner — this is a
re-sync in progress, not "indexer down", so don't block the UI. Health responses are cached about
5 seconds server-side, so don't poll faster than that.
Render it safely
For escaping untrusted Poster content (XSS), rendering by trust_level, normalizing addresses to
lowercase, and realtime reconnection rules, see the
Frontend integration page. For the contracts the indexer
watches and the full event list, see Contracts and
Architecture. Navigator addresses are discovered dynamically from
on-chain events — no static config is needed.