GuidesDatabase

Database Schema

PostgreSQL (Neon). All tables live in the public schema. Migration runner: npx tsx db/migrate.ts (tracks applied files in schema_migrations).


Database Architecture Overview

Challenge lifecycle data flow

User submits evidence (webapp API)


public.evidence          ← written by: POST /api/aivm/intake, evidenceCollector worker

        ▼ evidenceEvaluator worker
public.verdicts          ← written by: evidenceEvaluator worker

        ▼ challengeDispatcher (gates on approved + verdict)
public.aivm_jobs         ← written by: challengeDispatcher, challengeWorker, aivmIndexer

        ▼ challengeWorker → requestInferenceV2 on-chain
        │   [Lightchain network: commit → reveal → attest]
        ▼ aivmIndexer → submitProofFor + ChallengePay.finalize()
public.challenges        ← written by: lightchain indexer (status updates)

        ▼ participant claims reward on-chain
public.claims            ← written by: POST /api/me/claims (UI path), claimsIndexer (indexer path)

Write sources per table

TableWritten by
challengesstatusIndexer (status sync from ChallengePay events), aivmIndexer (finalization bridge), webapp API (create)
participantsPOST /api/challenge/[id]/participant, POST /api/aivm/intake
evidencePOST /api/aivm/intake, evidenceCollector worker
verdictsevidenceEvaluator worker
aivm_jobschallengeDispatcher (insert), challengeWorker (status), aivmIndexer (status)
claimsPOST /api/me/claims (UI, source=ui), claimsIndexer (source=indexer)
modelsPUT /api/admin/models
challenge_templatesPUT /api/admin/templates
linked_accountsPOST /api/accounts/link, OAuth callback routes
identity_bindingsPOST /api/auth/steam/return, offchain/identity/registry.ts
indexer_stateaivmIndexer, claimsIndexer (checkpoint writes)
remindersPOST /api/reminders
challenge_invitesPOST /api/invites
openid_noncesGET /api/auth/steam

Table relationships

challenges (1) ──── (N) participants
challenges (1) ──── (N) evidence
challenges (1) ──── (1) aivm_jobs
challenges (1) ──── (N) verdicts
challenges (1) ──── (N) claims
challenges (1) ──── (N) reminders
challenges (1) ──── (N) challenge_invites

participants (challenge_id + subject) ← joined by evidence, verdicts, claims

linked_accounts ── feeds ──► evidenceCollector ──► evidence
identity_bindings ── used by ──► OpenDota / Riot adapters
models ── referenced by ──► challenge_templates, challenge proof params

Tables overview

TableMigrationPurpose
evidence001Raw evidence records submitted for a (challenge, subject) pair
verdicts001Evaluation outcomes — one per (challenge, subject), upserted
identity_bindings002Wallet ↔ platform account mappings (Steam, Riot, Epic)
openid_nonces002Short-lived nonces for OpenID Connect replay protection
participants003, 014Off-chain cache of challenge join records (014 adds source column)
linked_accounts004OAuth tokens and external IDs for provider integrations
challenge_templates005Admin-managed challenge templates (DB-backed)
challenge_invites006Queued invites (email / wallet / Steam) for challenges
models007AIVM model registry (migrated from models.json)
challenges008, 013, 015Indexed on-chain challenge state (013 adds chain_outcome, 015 adds registry tracking)
aivm_jobs009AIVM job queue (managed by lightchain dispatcher/worker)
indexer_state010Key/value checkpoint state for indexer workers
reminders011Email reminder subscriptions for challenge deadlines
claims012Persisted on-chain claim events (rewards claimed by participants)
achievement_mints016Soulbound achievement token mints indexed from on-chain events
reputation016Computed reputation scores and levels per wallet

evidence

Stores normalized evidence records submitted for a (challenge_id, subject) pair. One row is inserted per ingestion event; multiple rows per challenge are allowed — the most recent row is used for evaluation.

ColumnTypeNotes
idbigserial PKAuto-increment
challenge_idbigint NOT NULLReferences challenges.id (no FK constraint)
subjecttext NOT NULLLowercase 0x wallet address of the participant
providertext NOT NULLSource: apple | garmin | strava | opendota | riot | steam | manual
datajsonb NOT NULLArray of canonical activity / game records
evidence_hashtext NOT NULLDeterministic hash of data (caller-computed, used for dedup)
raw_reftextOptional reference to raw source (S3 key, upload path, etc.)
created_attimestamptz NOT NULLInsert time
updated_attimestamptz NOT NULLLast update time

Indexes: (challenge_id), (challenge_id, lower(subject))

Key behaviours:

  • The evaluator worker reads the latest row per (challenge_id, lower(subject), provider).
  • The evidence collector worker skips insertion when evidence_hash matches the previous row (no new data).
  • challenge_id = 0 is used for preview/test submissions that are not linked to a live challenge.

verdicts

Stores the result of evaluating evidence for a (challenge_id, subject) pair. One verdict per pair — subsequent evaluations UPSERT the existing row.

Authoritative for: verdict/pass status. Written by offchain/workers/evidenceEvaluator.ts.

ColumnTypeNotes
idbigserial PKAuto-increment
challenge_idbigint NOT NULL
subjecttext NOT NULLLowercase 0x wallet address
passboolean NOT NULLWhether the subject satisfied the challenge rules
reasonstext[] NOT NULLHuman-readable failure reasons (empty array on pass)
evidence_hashtext NOT NULLHash of the evidence that produced this verdict
evaluatortext NOT NULLWhich evaluator ran: fitness, gaming, passthrough, etc.
created_attimestamptz NOT NULLFirst evaluation time
updated_attimestamptz NOT NULLLast re-evaluation time

Unique constraint: (challenge_id, subject) — one verdict per participant per challenge.

Indexes: (challenge_id)

Used by: /api/challenges/[id]/progress, /api/challenges/[id]/claim, evidence evaluator worker.


identity_bindings

Maps a wallet address to a platform account (Steam, Riot, Epic Games). Used to verify gaming identity in the challenge flow.

ColumnTypeNotes
idbigserial PK
wallettext NOT NULLLowercase 0x address
platformtext NOT NULLsteam | riot | epic
platform_idtext NOT NULLPlatform-specific user ID (Steam64, PUUID, etc.)
handletextDisplay name / username (optional)
signed_bytextOperator address that countersigned the binding
signaturetextEIP-191 personal_sign of the binding JSON
tsbigint NOT NULLUnix milliseconds at time of binding
created_attimestamptz NOT NULL
updated_attimestamptz NOT NULL

Unique constraint: (wallet, platform) — one platform account per wallet.

Indexes: (wallet), (platform, platform_id)


openid_nonces

Short-lived nonces for OpenID Connect / OAuth state replay protection. Replaces the previous file-based openid_nonce.json store.

ColumnTypeNotes
idbigserial PK
noncetext NOT NULL UNIQUERandom nonce string
expires_attimestamptz NOT NULLAfter this time the nonce is invalid
created_attimestamptz NOT NULL

Indexes: (expires_at) — supports periodic cleanup of expired rows.


participants

Off-chain cache of challenge join records. Populated when a user calls POST /api/challenge/[id]/participant after a successful on-chain joinChallenge transaction, and also upserted automatically when evidence is submitted for a non-zero challenge_id.

The on-chain Joined events remain the authoritative record; this table is a fast queryable cache used by the “My Challenges” page and status APIs.

Participant row sources

A participant row may be created by two paths:

Pathtx_hashjoined_atNotes
POST /api/challenge/[id]/participantsetsetCalled by frontend after on-chain joinChallenge tx
POST /api/aivm/intake (evidence upload)nullnullEvidence submitted without explicit on-chain join

Policy: Both paths produce valid participant rows. Evidence-intake participants have joined_at = null. This is acceptable — it means the user submitted evidence but may not have staked on-chain via joinChallenge.

Reward integrity is NOT compromised by evidence-intake participants because:

  • All reward/claim eligibility is determined by on-chain simulation (claimEligible), not by this table
  • chain_outcome from challenges.chain_outcome is the authoritative final outcome
  • If there is no on-chain stake, claimEligible = false and no payout is offered

Lifecycle impact: Evidence-intake participants appear in “My Challenges” so users can track their submission status. This is intentional — a user who uploaded garmin data without a formal join tx should still see their evidence status.

ColumnTypeNotes
idbigserial PK
challenge_idbigint NOT NULL
subjecttext NOT NULLLowercase 0x wallet address
tx_hashtextOn-chain join tx hash — NULL for evidence-intake participants
joined_attimestamptzTimestamp of on-chain join — NULL for evidence-intake participants
sourcetext NOT NULL DEFAULT 'unknown'Provenance: onchain_join | evidence_intake | unknown
created_attimestamptz NOT NULLRow creation time
updated_attimestamptz NOT NULLLast upsert time

Unique index: (challenge_id, lower(subject)) — one row per participant per challenge.

Indexes: (lower(subject)), (challenge_id)

Used by: GET /api/me/challenges, GET /api/challenge/[id]/participant, evidence collector worker.


linked_accounts

Stores OAuth tokens and external IDs for provider accounts connected to a wallet. Used by the evidence collector worker (offchain/workers/evidenceCollector.ts) to pull live activity/match data from provider APIs on a polling schedule.

ColumnTypeNotes
idbigserial PK
subjecttext NOT NULLLowercase 0x wallet address
providertext NOT NULLstrava | opendota | riot | apple
external_idtextProvider user/athlete ID (required for opendota/riot; optional for strava)
access_tokentextOAuth access token (Strava, Riot) — store encrypted in production
refresh_tokentextOAuth refresh token (Strava) — store encrypted in production
token_expires_attimestamptzToken expiry; NULL = no expiry or unknown
created_attimestamptz NOT NULL
updated_attimestamptz NOT NULLUpdated on token refresh

Unique index: (lower(subject), provider) — one linked account per provider per wallet.

Indexes: (lower(subject)), (provider)

API: GET/POST/DELETE /api/accounts/link

Security note: access_token and refresh_token are stored in plaintext. Use database-level encryption or a secrets vault in production.


challenge_templates

Admin-managed challenge templates. The runtime code-side templates in webapp/lib/templates.ts remain authoritative for the challenge-creation flow (they carry paramsBuilder and ruleBuilder functions which cannot be serialised).

This table allows admins to add, edit, or disable templates without a code deploy. It is the backend for GET /api/admin/templates and PUT /api/admin/templates.

ColumnTypeNotes
idtext PKMatches the code-side template id, e.g. running_window
nametext NOT NULLDisplay name shown in the create UI
hinttextShort description shown below the name
kindtext NOT NULLsteps | running | cycling | hiking | swimming | dota | lol | cs
model_idtext NOT NULLAIVM model identifier, e.g. strava.distance_in_window@1
fields_jsonjsonb NOT NULLArray of TemplateField descriptors (serialisable subset — no function values)
rule_configjsonbCanonical Rule or GamingRule object embedded in proof.params.rule
activeboolean NOT NULL DEFAULT truefalse = soft-deleted / hidden from create UI
created_attimestamptz NOT NULL
updated_attimestamptz NOT NULL

Indexes: (kind), (active)

Merge behaviour: At runtime, templateRegistry.ts merges DB rows with code-side templates. The DB row’s name, hint, and fields_json override the code-side values; paramsBuilder and ruleBuilder always come from code.


challenge_invites

Queued invites for challenges. Created by POST /api/invites and listed by GET /api/invites. A background job (not yet implemented) is expected to process queued rows and transition them to sent / accepted / failed.

ColumnTypeNotes
idtext PKUUID generated by the API handler
challenge_idbigint NOT NULLTarget challenge
methodtext NOT NULLemail | wallet | steam
valuetext NOT NULLEmail address, 0x wallet, or Steam64 ID
statustext NOT NULL DEFAULT queuedqueuedsent | accepted | failed
created_attimestamptz NOT NULL
updated_attimestamptz NOT NULL

Indexes: (challenge_id), (status)


models

AIVM model registry. Migrated from webapp/public/models/models.json (migration 007). Read by offchain/db/models.ts and served via GET /api/admin/models.

The webapp/public/models/models.json file is archived — its content reads: "_archived": "Migrated to public.models DB table (migration 007_models.sql)".

ColumnTypeNotes
idtext PKModel identifier, e.g. strava.distance_in_window@1
labeltext NOT NULLHuman-readable name
kindtext NOT NULLActive: aivm | custom. Legacy (backward compat only): zk | plonk
model_hashtext NOT NULLOn-chain model hash (bytes32)
verifiertext NOT NULLVerifier contract address (AIVM PoI verifier for active models)
plonk_verifiertextLegacy — not used by active product flows. Retained for backward compatibility with existing data.
bindingboolean NOT NULLWhether this model requires a task binding
signalsjsonb NOT NULLAIVM signal schema
params_schemajsonb NOT NULLJSON schema for proof params validation
sourcesjsonb NOT NULLAccepted evidence source providers
file_acceptjsonb NOT NULLAccepted file MIME types for evidence upload
notestextFree-text notes for admin UI
activeboolean NOT NULL DEFAULT truefalse = hidden from model picker
created_attimestamptz NOT NULL
updated_attimestamptz NOT NULL

Indexes: (kind), (active)

API: GET /api/admin/models, PUT /api/admin/models

Legacy compatibility: The seed data (migration 007) includes one legacy ZK model (strava.distance_in_window@1 with kind='zk'). This is retained for backward compatibility only. The plonk_verifier column is a legacy field with no active readers. New models should use kind='aivm' or kind='custom'.


challenges

Indexed on-chain challenge state. Written by the lightchain indexer as ChallengePay events are observed on-chain. Read by evaluators, APIs, and the evidence collector.

ColumnTypeNotes
idbigint PKOn-chain challenge ID
titletextChallenge title
descriptiontextChallenge description
subjecttextLowercase 0x wallet of the challenge creator / subject
tx_hashtextCreation transaction hash
model_idtextAIVM model identifier
model_hashtextOn-chain model hash (bytes32)
paramsjsonbLegacy / top-level rule fallback
proofjsonbFull proof config — proof.params.rule contains the evaluator Rule
timelinejsonb{start, end} Unix timestamps
fundsjsonbStake and reward amounts
optionsjsonbMiscellaneous challenge options
statustextpending | approved | finalized | canceled | rejected — written by statusIndexer
chain_outcomesmallint0=None, 1=Success, 2=Fail — from Finalized event. NULL until finalized. AUTHORITATIVE for reward eligibility.
aivm_request_startedbooleanDeprecated — superseded by aivm_jobs.status; will be dropped
aivm_request_started_attimestamptzDeprecated — superseded by aivm_jobs.status; will be dropped
created_attimestamptz
updated_attimestamptz

Indexes: (status), (created_at), (status, created_at), and several on proof jsonb fields.

Authoritative source: on-chain events via statusIndexer. Do not write directly.

Source-of-truth rules for finalized challenges:

  1. chain_outcome = 2 (Fail) → no reward regardless of DB verdict_pass
  2. chain_outcome = 1 (Success) + verdict_pass = true + claimEligible = true (on-chain simulation) → reward claimable
  3. chain_outcome = null → outcome not yet indexed; fall back to verdict_pass + claimEligible

aivm_jobs

AIVM job queue. One row per challenge; the status machine drives the evaluation pipeline. Managed by offchain/dispatchers/challengeDispatcher.ts and offchain/workers/challengeWorker.ts.

ColumnTypeNotes
idbigserial PK
challenge_idbigint NOT NULL UNIQUEReferences challenges.id
statustext NOT NULLSee status flow below
attemptsint NOT NULLRetry counter
last_errortextLast error message (for failed rows)
worker_addresstextWallet address of the worker that processed the job
task_idtextAIVM task identifier (set after requestInferenceV2)
created_attimestamptz NOT NULL
updated_attimestamptz NOT NULL

Unique constraint: (challenge_id) — one job per challenge.

Indexes: (status, created_at) (for worker polling), (task_id) (for indexer lookup)

Job status flow:

queued → processing → submitted → committed → revealed → done
                    ↘ failed (on error, retried up to max_attempts)
                    ↘ dead   (exhausted max_attempts — terminal, no more retries)
                    ↘ canceled (challenge became Finalized/Rejected/Canceled before submission)
skipped             (challenge was already finalized when worker ran — no-op)

Terminal states: done, dead, canceled, skipped — worker will never pick these up again.

canceled policy: The challengeDispatcher runs cancelTerminalJobs() each poll cycle, setting any queued/failed/processing job to canceled when its challenge has reached a terminal on-chain status. The challengeWorker also guards against this at claim time via a JOIN filter. Run scripts/ops/cancelTerminalJobs.ts to fix any pre-existing stale rows.

Worker does not call markJobDone on success — the aivmIndexer transitions to done when it observes an InferenceFinalized event for the matching task_id.


indexer_state

Key/value checkpoint store for indexer workers. Each indexer stores its last-processed block number here so it can resume after restart without re-scanning from genesis.

ColumnTypeNotes
keytext PKIndexer identifier, e.g. last_aivm_block, last_claims_block
valuetextSerialised state value (block number as decimal string)

Known keys:

  • last_aivm_block — used by offchain/indexers/aivmIndexer.ts
  • last_claims_block — used by offchain/indexers/claimsIndexer.ts

reminders

Email reminder subscriptions for challenge proof deadlines. Created when a user opts in on the challenge page.

ColumnTypeNotes
idbigserial PK
emailtext NOT NULLRecipient email address
challenge_idbigint NOT NULLFK → challenges.id
typetext NOT NULLReminder type: proof_deadline | finalization
sentboolean NOT NULL DEFAULT falseWhether the reminder has been sent
created_attimestamptz NOT NULL
sent_attimestamptzTimestamp when the reminder was sent

Unique constraint: (email, challenge_id, type) — no duplicate reminders.

Indexes: (sent, created_at) for pending-reminder worker queries.

FK: challenge_idpublic.challenges(id)


claims

Persists on-chain claim events for challenge participants. One row per (challenge_id, subject, claim_type) — upserted on conflict so both write paths are idempotent and the same claim is never recorded twice.

Write paths:

  1. UI path (primary): After the user’s wallet submits a claimETH / claimPrincipal / etc. transaction successfully, the frontend POSTs to POST /api/me/claims with source='ui'.
  2. Indexer path (secondary/hardening): offchain/indexers/claimsIndexer.ts watches ChallengePay *Claimed events and Treasury ClaimedETH events, upserting with source='indexer'. The indexer is the authoritative source of truth — if source='indexer', UI writes do not downgrade it.

Authoritative for: CLAIMED lifecycle state. resolveLifecycle() reads hasClaim (derived from this table) and treats it as the highest-priority signal for the CLAIMED stage.

ColumnTypeNotes
idbigserial PKAuto-increment
challenge_idbigint NOT NULLOn-chain challenge ID
subjecttext NOT NULLLowercase 0x wallet address of the claimant
claim_typetext NOT NULLprincipal | cashback | validator_reward | validator_reject | reject_creator | reject_contribution | treasury_eth
amount_weinumeric(78,0) NOT NULLClaim amount in wei (0 = unknown at write time)
bucket_idbigintTreasury bucket ID (= challenge_id for most claims)
tx_hashtextOn-chain transaction hash (null if not yet known)
block_numberbigintBlock where the claim tx was mined
sourcetext NOT NULLui or indexer; indexer takes precedence on conflict
metadatajsonbOptional extra data (gas, raw event args, etc.)
claimed_attimestamptz NOT NULLTimestamp of the claim (defaults to now())
created_attimestamptz NOT NULLRow creation time
updated_attimestamptz NOT NULLLast upsert time

Indexes:

Index nameDefinitionNotes
claims_pkeyUNIQUE (id)Primary key
claims_challenge_subject_type_uqUNIQUE (challenge_id, lower(subject), claim_type)Prevents duplicate claims; conflict target for upserts
claims_subject_idx(lower(subject))Fast lookup for “my claims” page
claims_challenge_id_idx(challenge_id)Fast lookup for challenge-level claim queries
claims_tx_hash_idx(tx_hash) WHERE tx_hash IS NOT NULLIndexer dedup by transaction hash

API: GET /api/me/claims?subject=0x..., POST /api/me/claims

Claim types ↔ ChallengePay events:

claim_typeContract event
principalPrincipalClaimed(id, user, amount)
cashbackCashbackClaimed(id, user, amount)
validator_rewardValidatorClaimed(id, validator, amount)
validator_rejectValidatorRejectClaimed(id, validator, amount)
reject_creatorRejectCreatorClaimed(id, creator, amount)
reject_contributionRejectContributionClaimed(id, user, amount)
treasury_ethClaimedETH(bucketId, to, amount) (Treasury contract)

achievement_mints

Indexes soulbound achievement token mints from ChallengeAchievement on-chain events. Two achievement types: completion (any finalized participant) and victory (winners only).

ColumnTypeNotes
idbigserial PKAuto-increment
token_idbigint NOT NULL UNIQUEOn-chain ERC-721 token ID
challenge_idbigint NOT NULLThe challenge this achievement is for
recipienttext NOT NULLLowercase 0x wallet address of the recipient
achievement_typetext NOT NULLcompletion | victory — enforced by CHECK constraint
tx_hashtextMint transaction hash
block_numberbigintBlock where the mint occurred
minted_attimestamptz NOT NULLTimestamp of the mint (defaults to now())
created_attimestamptz NOT NULLRow creation time

Indexes: (lower(recipient)), (challenge_id)

Used by: GET /api/me/achievements, reputation engine.


reputation

Computed reputation scores per wallet, derived from achievement mints. Updated off-chain after each achievement mint. Drives the level system shown in the user profile.

Level thresholds: 1=Newcomer, 2=Challenger, 3=Competitor, 4=Champion, 5=Legend (point-based).

ColumnTypeNotes
subjecttext PKLowercase 0x wallet address
pointsinteger NOT NULL DEFAULT 0Total reputation points
levelinteger NOT NULL DEFAULT 1Current level (1-5)
completionsinteger NOT NULL DEFAULT 0Number of completion achievements
victoriesinteger NOT NULL DEFAULT 0Number of victory achievements
updated_attimestamptz NOT NULLLast recomputation time

Used by: GET /api/me/reputation, achievements page.


Authoritative sources summary

DataAuthoritative source
Challenge on-chain statepublic.challenges (written by lightchain indexer)
Challenge outcomepublic.challenges.chain_outcome (written by statusIndexer)
Verdict / pass statuspublic.verdicts (written by evidenceEvaluator.ts)
Claimable stateLive on-chain simulateContract check (real-time)
Claimed statepublic.claims (written by UI post-tx + claimsIndexer.ts)
AIVM job statuspublic.aivm_jobs (written by dispatcher/worker/aivmIndexer)
Indexer checkpointspublic.indexer_state
Achievement mintspublic.achievement_mints (indexed from on-chain ChallengeAchievement events)
Reputationpublic.reputation (computed off-chain from achievement_mints)

Running migrations

Use the migration runner (tracks applied files in schema_migrations):

npx tsx db/migrate.ts

This is idempotent — safe to re-run. Already-applied migrations are skipped.

Fallback with raw Node when tsx is unavailable:

node -e "
const { Pool } = require('pg');
const fs = require('fs');
const pool = new Pool({ connectionString: process.env.DATABASE_URL, ssl: { rejectUnauthorized: false } });
const files = fs.readdirSync('db/migrations').sort().map(f => 'db/migrations/' + f);
(async () => {
  for (const f of files) {
    await pool.query(fs.readFileSync(f, 'utf8'));
    console.log('OK:', f);
  }
  await pool.end();
})().catch(e => { console.error(e.message); process.exit(1); });
"

Note: The raw Node fallback does not update schema_migrations. Use npx tsx db/migrate.ts wherever possible.