Skip to content
DocsDevelopersIndexer & data layer

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_yh3OhLi2

That 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.

TablePurposeKey frontend columns
ds_daosDAO records — governance params, token totals, profileid (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_membersMember share/loot balances, delegation, vote countsid, dao_id, member_address, shares, loot, delegating_to, voting_power, votes, last_activity_at
ds_proposalsProposal 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_votesIndividual vote recordsproposal_id, voter, approved, balance, block_number
ds_navigatorsRegistered navigators with type, permission, and trustsee Navigator discovery
ds_navigator_eventsGeneric onboard events emitted by navigatorsdao_id, navigator_address, event_type (onboard), contributor, shares_minted, loot_minted, amount
ds_ragequitsMember exit records with per-token amountsdao_id, member_address, to_address, shares_burned, loot_burned, tokens[], amounts[]
ds_recordsPoster metadata (profiles, rationale, announcements)dao_id (nullable), user_address, tag, content_type, content (RAW/UNTRUSTED), content_json (validated), trust_level, block_number
ds_guild_tokensRegistered ragequit tokensdao_id, token_address, enabled
ds_delegationsDelegation change historydao_id, delegator, from_delegate, to_delegate, tx_hash
ds_event_transactions / ds_processed_logsDedup tracking for retry idempotency(internal)
ds_indexer_stateLast processed block and sync statelast_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.

TableKey columns
ds_nft_claimsid (= 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).

TableKey columns
ds_signal_pollsnavigator_address, poll_id, creator, question, option_count, snapshot_timestamp, voting_starts, voting_ends, cancelled, tally (NUMERIC[], derived), options (off-chain labels)
ds_signal_votespoll_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.

TableKey columns
ds_timelock_changesnavigator_address, change_id, queued_by, config_hash, governance_config (FULL bytes), executable_after, expires_at, status (queued / executed / cancelled)
ds_governance_config_historydao_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.

TableKey columns
ds_vesting_schedulesnavigator_address, schedule_id, beneficiary, total_amount, claimed (SUM of claims), is_loot, start_time, cliff_end, vesting_end, revoked, revoked_at
ds_vesting_claimsincremental 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'.

TableKey columns
ds_budgetsnavigator_address, budget_id, manager, token (0x0 = native), allowance_per_period, total_ceiling, total_spent (SUM), period_length, starts_at, ends_at, cancelled
ds_budget_disbursementsrecipient, token, amount (one row per recipient)
ds_vault_module_eventsvault, 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.

TableKey columns
ds_subscription_membersmember, paid_through (absolute unix secs), total_paid (SUM), last_collected_at
ds_subscription_paymentsmember, payer, token, amount, periods, paid_through
ds_subscription_collectionsmember, 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.

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.

ColumnMeaning
dao_idThe DAO this navigator names (bound at deploy for every navigator)
navigator_addressThe navigator contract
navigator_typeOnboarderNavigator, ERC20TributeNavigator, NFTGatedNavigator, SignalNavigator, TimelockNavigator, VestingNavigator, BudgetNavigator, SubscriptionNavigator
permissionPermission bitmask (0 = none)
permission_labelHuman label for the bitmask (none, admin, manager, governor, all, etc.)
permission_ever_grantedTRUE once a NavigatorSet with permission > 0 was seen — distinguishes a REVOKED nav from a born read-only one
trust_statusself_asserted, sanctioned, unsanctioned, or fabricated
is_active"Functional now?" — NOT "has permission"
pausedWhether 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 born sanctioned.
  • Read-only (Signal) — holds no permission. Sanctioned via a vault daoships.dao.navigators Poster post; its poll history is backfilled on sanction.
  • Module (Budget) — authority is being an enabled Zodiac module on the vault. Sanctioned via a vault EnabledModule event (the indexer derives trust from ds_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 double

If 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 upstream

Then 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.