Elgora docs

The subgraph

What a subgraph is, why Elgora's is the authoritative read model, when you need it, and how to query it directly.

What a subgraph is

A smart contract's history lives in its events — small records the contract emits as it executes. They are permanent and cheap to write, but the chain gives you no way to ask a question like "every open bounty past its deadline, with the Verdicts recorded so far".

A subgraph solves that. An indexer replays the contract's events from a chosen start block, applies a small handler for each event, and builds a queryable database out of them. You get a GraphQL endpoint over entities — bounties, submissions, verdicts, claims — instead of an event log you would have to reassemble yourself.

Two properties matter here:

  • Derived, and rebuildable. Everything in it comes from contract events. If the whole index were deleted, replaying from the deploy block reproduces it exactly. It stores no fact the chain does not already have.
  • Eventually consistent. It follows the chain with a small lag. A transaction that just landed may not be indexed for a few blocks.

Why Elgora's subgraph is the authoritative read model

"Authoritative" here means: for lists, history, and joins, the subgraph is the answer, and Elgora's own clients and app use it rather than assembling those views some other way. It is the shared projection, so two consumers asking the same question get the same answer. The Guardian Analytics fee summary is also explicitly pinned to this read model.

Authoritative for reading, never for deciding

The contract remains the sole authority for the lifecycle, escrow, Verdict agreement, settlement, claims, and refunds. A projection cannot authorize any of those. When they disagree, the subgraph is simply behind — read the contract directly whenever you are about to send a transaction that depends on the answer.

That is exactly how the clients behave: they use the subgraph to find work and read the contract to act on it.

You usually do not need it

For the ordinary Poster and Solver flows, you do not have to touch GraphQL at all:

  • GET /api/bounties/{bounty_id} gives you one bounty, its roster, its submissions, and the committed challenge.
  • elgora-cli verification-record <bounty_id> gives you the settled summary.
  • The CLI queries the subgraph for you where it needs to.

Query it directly when you want something those do not answer: a list across many bounties, a history, Verdicts as they land before settlement, or your own analytics.

Querying it

The endpoint is a standard GraphQL POST. It needs no key and no signature.

curl -s "$ELGORA_SUBGRAPH_ENDPOINT" \
  -H 'content-type: application/json' \
  -d '{"query":"{ bounties(first: 5, orderBy: bountyId, orderDirection: desc) { bountyId status escrowAmount submissionDeadline } }"}'

The endpoint for the public deployment is in References and addresses.

One bounty, with everything attached

{
  bounties(first: 1, where: { bountyId: "12" }) {
    bountyId
    poster
    status
    specCommitment
    escrowAmount
    guardianRosterHash
    submissionDeadline
    judgingDeadlineAt
    settlementTimeoutAt
    treasuryFeeBps
    guardianFeeBps
    winner
    consensusReachedAt
    settledAt
    settledTxHash
    supportingGuardians
    reportCommitments
    submissions {
      solver
      submissionCommitment
      recordedAt
      recordedTxHash
    }
    verdicts {
      guardian
      outcome
      winner
      awardedSubmissionCommitment
      reportCommitment
      committedAt
      updatedAt
    }
  }
}

This is the query that answers "how is judging going" — every Guardian's current Verdict, before settlement, in one round trip.

Open bounties whose deadline has passed

{
  bounties(
    where: { status: Open, submissionDeadline_lt: "1801699200" }
    orderBy: submissionDeadline
    orderDirection: asc
    first: 50
  ) {
    bountyId
    submissionDeadline
    judgingDeadlineAt
    settlementTimeoutAt
    submissionCount
  }
}

The current Guardian roster and protocol configuration

Protocol is a singleton — exactly one row for the whole deployment, keyed by the Hub address. Query it with that address as id, and it comes back as a plain object, not a list. The live roster is nested inside it, not a separate singleton:

{
  protocol(id: "0x<hub_address>") {
    guardianRosterAdmin
    guardianRoster {
      id
      memberCount
      members {
        account
        name
        encryptionPublicKey
      }
    }
    guardianFeeRecipient
    treasuryFeeBps
    guardianFeeBps
    guardianFeeClaimable
    paused
  }
}

One request returns the current indexed configuration, the live roster, and the global Guardian fee pool. The mapping maintains guardianFeeClaimable as events arrive: each Settled event adds that Bounty's Guardian fee allocation, recorded by the final ClaimQueued immediately before Settled (including zero), while a nonzero Guardian amount in FeesClaimed drains it. Analytics pins this Protocol query to the same indexed block as its other results; it never scans every Bounty or FeeClaim to rebuild the total.

The live fee rate is not a bounty's own pinned value, which is a separate, permanent fact recorded on that Bounty. The global pool is likewise not a per-Bounty or per-Guardian entitlement.

The @elgora/subgraph-client package wraps this: construct it with hubAddress once, and client.getDeploymentConfig() reads the protocol config (roster included) and the active-Guardian list in one request. Pass an indexedBlockNumber to pin it to that block; omit it to read the latest indexed state.

A historical Guardian roster

{
  guardianRosterSnapshot(id: "0x<guardian_roster_hash>") {
    memberCount
    members {
      account
      name
      encryptionPublicKey
    }
  }
}

The contract gives full details only for the current roster; this is how you resolve the names and encryption keys behind a roster hash a bounty pinned earlier. Always re-derive the hash and compare it with the bounty's pinned value before you trust the result — see The Guardian roster.

How far behind is it?

{ _meta { block { number } hasIndexingErrors } }

Worth checking before you conclude that something is missing.

The entities

EntityOne row perNotes
BountyBountyCreation facts, pinned parameters, first-agreement stamp, final outcome, supporting Guardians and their report commitments
Submission(bounty, solver)A re-submission updates the row, matching the contract's one-active-Submission-per-Solver rule
Verdict(bounty, guardian)Overwritten by a revision. Carries outcome, named winner, awarded commitment, and reportCommitment
ClaimQueuedEventFees and refunds queued at settlement or timeout
ClaimEventActual pulls, including award claims
GuardianGuardian addressCurrent roster identity, with an active flag; removed Guardians keep their historical row
GuardianRosterSnapshotRoster hashThe ordered members frozen at that hash, immutable
GuardianRosterMember(roster hash, account)One frozen identity, referenced in order from a GuardianRosterSnapshot.members
ProtocolHub address (singleton)Live configuration, the live roster (guardianRoster), and the event-maintained global guardianFeeClaimable pool — protocol(id: "0x<hub_address>") returns one plain object, never a list, since there is exactly one row
FeeClaimEventOne immutable row per claimFees() pull, independent of any single bounty's Claim

Two things it does not hold. Written Verdict content is deliberate — the subgraph stores the reportCommitment, and the document itself is served by the API. And unlike a Bounty's own pinned parameters, Protocol exposes no change-history rows: a config or roster change updates the singleton. A block-pinned query can read that singleton at one indexed block, but it does not return a list of changes. See References and addresses for where each value lives.

Note the difference from a bounty's own configuration: the fee rates, recipients, payout scheme, roster hash, and deadlines a bounty snapshotted at creation are all on Bounty, and never move.

Notes for integrators

  • Bounty.id is the 32-byte big-endian bounty id, so it sorts numerically; use the bountyId field for human ordering.
  • consensusReachedAt records the first threshold crossing and never moves, even if Guardians later disagree and re-agree. It is a historical fact, not proof of current agreement.
  • submissionCount on a bounty counts accepted submit calls, while Submission rows are one per Solver — so after a re-submission the count is legitimately larger than the row count.
  • awardedPayoutData is passed through verbatim and never decoded by the projection.
  • A different operator running their own indexer against the same contract can point clients at it with ELGORA_SUBGRAPH_ENDPOINT. Its start block must be at or before the Hub's deploy block, or it will miss the events that create bounties.

Read this page as Markdown

On this page