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 (wallet invites processed inline; email/steam queued for background worker)
notificationsPOST /api/invites (inline for wallet invites), POST /api/v1/notifications, offchain alert workers
openid_noncesGET /api/auth/steam
user_profilesGET/PUT /api/me/profile
competitionsPOST /api/competitions
competition_registrationsPOST /api/competitions/[id]/register
organizationsPOST /api/org/new
org_membersPOST /api/org/[slug]/members
seasonsCompetition admin API
season_competitionsCompetition admin API
season_standingsCompetition scoring worker
teamsOrganization admin API
team_rosterOrganization admin API
bracket_matchesCompetition bracket engine
match_disputesPOST /api/competitions/[id]/disputes
api_keysOrganization admin API
webhooksOrganization admin API
webhook_deliveriesWebhook delivery worker
whitelabel_configsOrganization admin API

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

notifications ── keyed by wallet ── displayed in webapp NotificationBell + iOS Activity

organizations (1) ──── (N) org_members
organizations (1) ──── (N) competitions
organizations (1) ──── (N) teams
organizations (1) ──── (N) api_keys
organizations (1) ──── (N) webhooks
organizations (1) ──── (1) whitelabel_configs
organizations (1) ──── (N) seasons

competitions (1) ──── (N) competition_registrations
competitions (1) ──── (N) bracket_matches
competitions (1) ──── (N) match_disputes

seasons (1) ──── (N) season_competitions
seasons (1) ──── (N) season_standings

teams (1) ──── (N) team_roster

Tables overview

Core challenge tables

TableMigrationPurpose
evidence001Raw evidence records submitted for a (challenge, subject) pair
verdicts001, 018Evaluation outcomes — one per (challenge, subject), upserted. 018 adds score, metadata
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_templates005, 031Admin-managed challenge templates. 031 renames kind stepswalking
challenge_invites006, 029, 030Challenge invites with inviter tracking and accept lifecycle
models007AIVM model registry (migrated from models.json)
challenges008, 013, 015Indexed on-chain challenge state
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

Notifications and user tables

TableMigrationPurpose
notifications021In-app notifications (invite received, claim available, alerts)
user_profiles022Display name, bio, and avatar per wallet

Competition platform tables

TableMigrationPurpose
competitions017Competitions (challenge bundles, brackets, leaderboards)
competition_registrations017Per-wallet or per-team registration for competitions
organizations017Organizations that own competitions and teams
org_members017Organization membership (owner, admin, member roles)
seasons017Seasons that group competitions for standings
season_competitions017Junction: season ↔ competition with weight
season_standings017Aggregated standings per wallet per season
teams017Teams within organizations
team_roster017Team membership (captain, player roles)
bracket_matches017Bracket/tournament match records
match_disputes021Dispute filings for bracket matches

API and integration tables

TableMigrationPurpose
api_keys017Scoped API keys for organizations
webhooks017Webhook endpoint registrations per organization
webhook_deliveries017Webhook delivery attempts and retry tracking
whitelabel_configs017Per-org white-label branding (domain, colors, logo)

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.
scorenumericCompetitive score for ranked challenges (NULL for pass/fail only)
metadatajsonbExtra evaluator output (breakdown, daily totals, 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 NULLwalking | running | cycling | hiking | swimming | strength | yoga | hiit | rowing | calories | exercise | 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.

Migration 031: Renamed kind stepswalking to unify step-counting and walking-distance challenges under one kind.


challenge_invites

Invites for challenges. Created by POST /api/invites and listed by GET /api/invites.

Wallet invites are processed inline: the API creates a notification for the target wallet and marks the invite as sent immediately. Email and Steam invites are queued for a background worker (status = 'queued').

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 queuedqueuedsentaccepted | failed
inviter_wallettextLowercase 0x wallet of the user who sent the invite
accepted_by_wallettextLowercase 0x wallet that accepted the invite (set on accept)
joined_attimestamptzTimestamp when the invitee joined the challenge
created_attimestamptz NOT NULL
updated_attimestamptz NOT NULL

Indexes: (challenge_id), (status)

Lifecycle:

queued ──► sent ──► accepted (invitee joined challenge)
                └─► failed   (delivery failed or expired)

For wallet invites, queuedsent happens inline in the POST handler. For email/steam, a background worker polls queued rows.

Accept flow: POST /api/invites/[id]/accept sets status = 'accepted', accepted_by_wallet, joined_at.


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.
registry_statustext DEFAULT 'pending'MetadataRegistry sync status
registry_tx_hashtextMetadataRegistry transaction hash
registry_errortextMetadataRegistry error message
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.


notifications

In-app notifications delivered to a wallet. Displayed in the webapp notification bell and the iOS Activity inbox. Created by invite handlers, alert workers, and the notification API.

ColumnTypeNotes
iduuid PKDefault gen_random_uuid()
wallettext NOT NULLLowercase 0x wallet of the recipient
typetext NOT NULLe.g. invite_received, claim_available, claim_reminder, proof_window_open, challenge_final_push
titletext NOT NULLNotification title
bodytextNotification body text
datajsonb NOT NULL DEFAULT '{}'Structured payload: { challengeId, inviteId, deepLink }
readboolean NOT NULL DEFAULT falseWhether the user has marked this as read
created_attimestamptz NOT NULL

Indexes: (lower(wallet), created_at DESC), (lower(wallet), read)

API: GET /api/v1/notifications?wallet=, POST /api/v1/notifications/mark-read


user_profiles

Display name, bio, and avatar for each wallet. One row per wallet.

ColumnTypeNotes
wallettext PKLowercase 0x wallet address
display_nametextUser-chosen display name
biotextShort bio
avatarbyteaAvatar image binary data
avatar_mimetext DEFAULT 'image/jpeg'MIME type of avatar
avatar_hashtextContent hash for cache busting
created_attimestamptz NOT NULL
updated_attimestamptz NOT NULL

API: GET/PUT /api/me/profile, GET /api/player/[wallet]


competitions

Competitions bundle multiple challenges or bracket matches under one umbrella. Owned by an organization.

ColumnTypeNotes
iduuid PKDefault gen_random_uuid()
org_iduuidFK → organizations.id
titletext NOT NULLCompetition title
descriptiontextDescription
typetext NOT NULL DEFAULT 'challenge'challenge | bracket | leaderboard
statustext NOT NULL DEFAULT 'draft'draft | open | active | completed | canceled
categorytextCategory label (fitness, gaming, etc.)
rulesjsonb NOT NULL DEFAULT '{}'Competition-specific rules
prize_configjsonb NOT NULL DEFAULT '{}'Prize structure and distribution
settingsjsonb NOT NULL DEFAULT '{}'Misc settings (max teams, format, etc.)
challenge_idsbigint[] NOT NULL DEFAULT '{}'On-chain challenge IDs included in this competition
registration_opens_attimestamptz
registration_closes_attimestamptz
starts_attimestamptz
ends_attimestamptz
created_bytextWallet that created the competition
created_attimestamptz NOT NULL
updated_attimestamptz NOT NULL

API: GET/POST /api/competitions, GET /api/competitions/[id]


competition_registrations

Per-wallet or per-team registration for a competition.

ColumnTypeNotes
iduuid PKDefault gen_random_uuid()
competition_iduuid NOT NULLFK → competitions.id
wallettextRegistered wallet (NULL for team-only registrations)
team_iduuidFK → teams.id (NULL for individual registrations)
seedintegerSeeding position for bracket tournaments
checked_inboolean NOT NULL DEFAULT falseWhether the participant has checked in
registered_attimestamptz NOT NULL

organizations

Organizations own competitions, teams, API keys, and webhooks.

ColumnTypeNotes
iduuid PKDefault gen_random_uuid()
nametext NOT NULLOrganization name
slugtext NOT NULL UNIQUEURL-safe slug
logo_urltextLogo URL
websitetextWebsite URL
descriptiontext
owner_wallettext NOT NULLLowercase 0x wallet of the owner
themejsonb NOT NULL DEFAULT '{}'Theme customization
created_attimestamptz NOT NULL
updated_attimestamptz NOT NULL

API: GET/POST /api/org, GET /api/org/[slug]


org_members

Organization membership. Roles: owner, admin, member.

ColumnTypeNotes
iduuid PK
org_iduuid NOT NULLFK → organizations.id
wallettext NOT NULLMember wallet
roletext NOT NULL DEFAULT 'member'owner | admin | member
emailtextOptional contact email
joined_attimestamptz NOT NULL

seasons

Seasons group competitions for aggregated standings and leaderboards.

ColumnTypeNotes
iduuid PK
org_iduuidFK → organizations.id
nametext NOT NULLSeason name
descriptiontext
statustext NOT NULL DEFAULT 'active'active | completed
scoring_configjsonb NOT NULL DEFAULT '{"win":3,"draw":1,"loss":0}'Points per outcome
starts_attimestamptz
ends_attimestamptz
created_attimestamptz NOT NULL

season_competitions

Junction table linking seasons to competitions with a weight multiplier.

ColumnTypeNotes
season_iduuid NOT NULLFK → seasons.id
competition_iduuid NOT NULLFK → competitions.id
weightfloat NOT NULL DEFAULT 1.0Score multiplier for this competition within the season

PK: (season_id, competition_id)


season_standings

Aggregated standings per wallet per season. Updated by the competition scoring worker.

ColumnTypeNotes
iduuid PK
season_iduuid NOT NULLFK → seasons.id
wallettext NOT NULL
pointsinteger NOT NULL DEFAULT 0Total season points
winsinteger NOT NULL DEFAULT 0
lossesinteger NOT NULL DEFAULT 0
drawsinteger NOT NULL DEFAULT 0
competitions_enteredinteger NOT NULL DEFAULT 0
updated_attimestamptz NOT NULL

Unique: (season_id, wallet)


teams

Teams within organizations, used for team-based competitions.

ColumnTypeNotes
iduuid PK
org_iduuid NOT NULLFK → organizations.id
nametext NOT NULLTeam name
tagtextShort tag / abbreviation
logo_urltext
created_attimestamptz NOT NULL

team_roster

Team membership. Roles: captain, player.

ColumnTypeNotes
iduuid PK
team_iduuid NOT NULLFK → teams.id
wallettext NOT NULLPlayer wallet
roletext NOT NULL DEFAULT 'player'captain | player
joined_attimestamptz NOT NULL

bracket_matches

Individual match records within bracket/tournament competitions.

ColumnTypeNotes
iduuid PK
competition_iduuid NOT NULLFK → competitions.id
roundinteger NOT NULLRound number (1-based)
match_numberinteger NOT NULLMatch position within the round
bracket_typetext NOT NULL DEFAULT 'winners'winners | losers | grand_final
participant_atextWallet or team ID
participant_btextWallet or team ID
score_ainteger
score_binteger
winnertextWallet or team ID of the winner
statustext NOT NULL DEFAULT 'pending'pending | in_progress | completed | disputed
challenge_idbigintOptional on-chain challenge backing this match
scheduled_attimestamptz
completed_attimestamptz
created_attimestamptz NOT NULL

match_disputes

Dispute filings for bracket matches. Filed by participants, resolved by org admins.

ColumnTypeNotes
iduuid PK
match_iduuid NOT NULLFK → bracket_matches.id
competition_iduuid NOT NULLFK → competitions.id
filed_bytext NOT NULLWallet that filed the dispute
reasontext NOT NULLDispute reason
evidence_urltextURL to supporting evidence
statustext NOT NULL DEFAULT 'open'open | resolved | dismissed
resolution_notetextAdmin resolution explanation
resolved_bytextWallet of the admin who resolved
created_attimestamptz NOT NULL
resolved_attimestamptz

api_keys

Scoped API keys for organizations. Keys are stored as hashed values; the raw key is shown once at creation time.

ColumnTypeNotes
iduuid PK
org_iduuid NOT NULLFK → organizations.id
key_hashtext NOT NULLSHA-256 hash of the API key
key_prefixtext NOT NULLFirst 8 chars of the key (for display)
labeltext NOT NULLHuman-readable label
scopestext[] NOT NULL DEFAULT '{}'Granted scopes
rate_limitinteger NOT NULL DEFAULT 1000Requests per hour
last_used_attimestamptz
expires_attimestamptzNULL = no expiry
created_attimestamptz NOT NULL
revoked_attimestamptzNULL = active; set to revoke

webhooks

Webhook endpoint registrations per organization. Events are delivered to the registered URL with an HMAC signature using the shared secret.

ColumnTypeNotes
iduuid PK
org_iduuid NOT NULLFK → organizations.id
urltext NOT NULLDelivery URL
secrettext NOT NULLHMAC shared secret
eventstext[] NOT NULL DEFAULT '{}'Subscribed event types
activeboolean NOT NULL DEFAULT truefalse = paused
created_attimestamptz NOT NULL

webhook_deliveries

Tracks individual webhook delivery attempts and retries.

ColumnTypeNotes
iduuid PK
webhook_iduuid NOT NULLFK → webhooks.id
eventtext NOT NULLEvent type that triggered delivery
payloadjsonb NOT NULLFull event payload sent
response_statusintegerHTTP status code from target
response_bodytextTruncated response body
attemptinteger NOT NULL DEFAULT 1Attempt number
delivered_attimestamptzSuccessful delivery time
next_retry_attimestamptzNext retry time (NULL if delivered or exhausted)
created_attimestamptz NOT NULL

whitelabel_configs

Per-organization white-label branding configuration. Applied when the webapp is accessed via the organization’s custom domain.

ColumnTypeNotes
iduuid PK
org_iduuid NOT NULL UNIQUEFK → organizations.id
custom_domaintextCustom domain (e.g. challenges.acme.com)
primary_colortext DEFAULT '#6B5CFF'Brand primary color
logo_urltextCustom logo URL
favicon_urltextCustom favicon URL
custom_csstextAdditional CSS overrides
footer_texttextCustom footer text
created_attimestamptz NOT NULL
updated_attimestamptz NOT NULL

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)
Notificationspublic.notifications (written by invite handler, alert workers)
User profilespublic.user_profiles (written by user via profile API)
Competition statepublic.competitions (written by competition admin API)
Organization statepublic.organizations (written by org creation API)

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.