A Nostr-based reputation and name protection system using committee-based ranking.
NymRank leverages Web-of-Trust (WoT) reputation scores for users of a given namespace. Instead of relying on a single authority for name issuance, it aggregates rankings from a specific set of committee members to create a multi-perspective view of name occupancy. It includes a search tool to check if a specific name or handle is occupied by a well-reputed user.
The API returns facts (average_rank, name_affinity, occupancy), not UX enums. A typical mapping for client apps:
| Situation | Suggested stance |
|---|---|
Name in reserved_names (DB) |
Not enforced by this API — the table exists in schema.sql, but GET /api/names does not read it; handle reserved names in your client if needed. |
| Occupied, average rank ≥ 95 | Strong discouragement (elite-tier signal). |
| Occupied, rank 75–94 | Discourage (established user). |
| Occupied, rank 35–74 | Caution (weaker claim; your product decides resolution). |
| Below rank 35 or not in ranked set | Weak signal; often treated like “available” for promotion flows. |
| Not occupied with affinity ≥ 2 | Available for registration; optional boost flows are product-specific. |
Affinity is 0–4: non-empty name 2, NIP-05 local part 1, LUD-16 local part 1 (see services/database.js). Search uses a per-query score (exact name +2, name prefix +1, nip05 +1, lud16 +1) with ≥ 2 required; the default search (services/aggregated-name-search.js) only considers rows where the handle matches name / name prefix or both nip05 and lud16. Perspective + search (routes/web.js) uses a broader WHERE but the same score formula. Details: event_analysis.md.
Sybil-fee processing, automated payment receipts, and referrer/committee onboarding are out of scope for the current service. The nymrank-boost/ package describes how client apps might combine API lookups with referrals and future paid boosts.
The system tracks delegation events from these initial committee members:
- justin:
3316e3696de74d39959127b9d842df57bddc5d1c7af8a04f1bc7aed80b445088 - straycat:
e5272de914bd301755c439b88e6959a43c9d2664831f093c51e9c799a16a102f - vinny:
2efaa715bbb46dd5be6b7da8d7700266d11674b913b8178addb5c2e63d987331
These keys are used to:
- Recognize delegation events (kind 10040) from committee members when ingested
- Map service keys to committee members for ranking events (kind 30382)
- Store per-member rows in
user_rankingsand average at query time (and viaprecomputed_rankings)
Ingestion note: Kind 10040 and 30382 are loaded by backfill-attestations.js (and optional JSONL importers). RelayListener only runs periodic kind 0 + activity fetches on social relays; it does not poll ranking relays for new delegations or attestations (handlers exist but are not invoked from startup).
- PostgreSQL 17 with a database named
nymrank - strfry compiled and available at
~/strfry/strfry- Clone:
git clone https://github.com/hoytech/strfry.git ~/strfry - Build:
cd ~/strfry && make
- Clone:
Before running the app, you must backfill attestations and delegations from the relay using negentropy:
node backfill-attestations.jsThis will:
- Use strfry sync with negentropy to download delegations (kind 10040) and attestations (kind 30382) from the configured relay for committee members
- Stream exported lines into the event processor (attestations limited to roughly the past week by timestamp in
backfill-attestations.js) - Persist into PostgreSQL (strfry db files remain under the strfry directory next to the repo — see script paths)
This is a one-time operation that may take several minutes.
If this is your first run:
psql -d nymrank -f schema.sqlAfter backfill:
npm startOr for development with auto-reload:
npm run devThe app will:
- Fetch profiles (kind 0) for all ranked users (1-day cooldown between fetches)
- Check for activity to update last-seen (background window 10 days; see Background activity checks below)
- Start the web UI on http://localhost:3333
- Multi-perspective Ranking: Averages reputation scores from all committee members
- Name Availability: Search tool to check if a name/handle is occupied
- Activity Tracking: Displays when users were last active ("Recently" for <7 days, "Xd ago" for 7-29 days, "Xmo ago" for 30+ days)
- Name Affinity Scoring: Scores based on name, NIP-05, and LUD-16 fields (partial name matches score lower)
- FAQ Page: Explains how to optimize profiles for name occupation
- Backfill: Negentropy sync via strfry for kind 10040 / 30382 into PostgreSQL (one-time or manual)
- Profile fetching: Batched kind-0 queries on social relays with 1-day cooldown (
profile_refresh_queue.last_profile_fetch) - Activity checking: Batched queries (any kind) on social relays with a 10-day window and tiered batch sizes (see Background activity checks)
- Rankings: Stored per committee member; averages computed in SQL / materialized view — not continuously synced from ranking relays after backfill unless you re-run import tools
- Materialized View:
precomputed_rankingsfor fast default list queries, refreshed on ranking changes (seeservices/database.js) - UI: Fastify web server with search, browse, pagination, and perspective switching
user_rankings: Individual rankings from each committee memberuser_names: Profile metadata (name, nip05, lud16) from kind 0 eventsprofile_refresh_queue: Tracks profile and activity fetch timestampsprofile_timestamp: Timestamp of the kind 0 eventlast_activity_timestamp: Most recent activity eventlast_profile_fetch: When we last fetched kind-0 profilelast_activity_check: When we last checked for activity events
precomputed_rankings aggregates rankings for the default list view, refreshed automatically on ranking changes.
GET /- Main search/browse UIGET /faq- FAQ page (served from/public/faq.html)GET /api-docs- Interactive API page (form inputs + live JSON responses)GET /api/status- API health/readinessGET /api/names/:name- Resolve name occupancy (pubkey,average_rank,name_affinity)GET /api/users/:pubkey/rank- Averaged user rank and committee breakdownGET /api/users/:pubkey/activity- Ad-hoc activity + profile refresh (hex or npub; same family as/api/users/:pubkey/rank)GET /log- Recent in-memory log tail (used for light debugging; not a structured log API)
- Default browse (
precomputed_rankings) and perspective browse queries do not apply arank ≥ 35SQL filter on the listed rows — anyone inuser_rankingscan appear. Search and occupied-nym counts userank ≥ 35(and search uses the match score rules in event_analysis.md). - Default and perspective views hide accounts whose last-seen (activity or kind-0 profile time) is older than 365 days (
LISTING_HIDE_LAST_SEEN_OLDER_THAN_DAYSinroutes/web.js), so the table is not dominated by long-dormant rows. Rows with unknown last-seen (no timestamps) still appear. - Total occupied nyms counts distinct pubkeys with
rank ≥ 35and stored `user_names.name_affinity ≥ 2**, independent of the 365-day list filter. Page count follows how many rows match the list (with the stale filter when enabled). - Append
?include_stale=1or?all=1to show everyone. Search is not filtered.
Uses one definition of recent: 10 days (same window for “activity in DB counts as fresh” and “time before we run another check”).
- Who gets checked (
rank_value ≥ 35): nolast_activity_timestamp, or it is older than 10 days, and we never checked activity orlast_activity_checkis older than 10 days. Anyone with activity in the DB within 10 days is skipped (no relay query that cycle). - Tier 1 — first pass, batches of 10 authors per relay filter.
- Tier 2 — after tier 1 in the same run, only for pubkeys that still have no activity in the last 10 days in the DB; batches of 3. If tier-1 eligibility is empty, neither tier runs (tier 2 is not a separate scheduler).
Periodic scheduling: a 6 hour setInterval triggers checks. While a run is in progress, overlapping ticks are skipped (profileCheckRunning). If a run did work (kind-0 fetch and/or activity tiers), a one-shot follow-up runs ~60s later so long backlogs can make progress without polling every minute when idle.
PORT: Server port (default: 3333, seeapp.js)DB_HOST: PostgreSQL host (default: localhost)DB_PORT: PostgreSQL port (default: 5432)DB_NAME: Database name (default: nymrank)DB_USER: Database user (default: nymrank_user)DB_PASSWORD: Database password (default: nymrank_password)RANKING_RELAY_URLS: Comma-separated relay list for ranking/delegation (default:wss://nip85.brainstorm.world)SOCIAL_RELAY_URLS: Comma-separated relay list shared by profile fetching and activity checks
Copy .env.example to .env and set secrets locally:
cp .env.example .envValues come from RANKING_RELAY_URLS and SOCIAL_RELAY_URLS (comma-separated). When unset, defaults are defined in services/config.js (DEFAULT_RANKING_RELAYS, DEFAULT_SOCIAL_RELAYS — the social list includes several public relays, not only three). See .env.example for a sample override.
{
"name": "alice",
"available": false,
"occupant": {
"pubkey": "abc123...",
"average_rank": 86,
"name_affinity": 3,
"profile": {
"name": "alice",
"nip05": "alice",
"lud16": "alice"
}
}
}{
"pubkey": "abc123...",
"average_rank": 82,
"average_influence_score": 0.74,
"average_hops": 2,
"average_follower_count": 318,
"perspective_count": 3,
"profile": {
"name": "alice",
"nip05": "alice",
"lud16": "alice",
"name_affinity": 4
},
"committee_breakdown": []
}Queries SOCIAL_RELAY_URLS for the author’s latest event (any kind) and kind 0 profile, then updates last_activity_check / last_activity_timestamp and profile fields when data is found. Errors use { "error": { "code", "message" } } like other /api routes.
{
"pubkey": "e5272de914bd301755c439b88e6959a43c9d2664831f093c51e9c799a16a102f",
"latest_event": {
"id": "...",
"kind": 1,
"created_at": 1730000000,
"created_at_iso": "2024-10-27T00:00:00.000Z",
"days_ago": 12
},
"total_events_found": 42,
"profile": {
"name": "alice",
"nip05": null,
"lud16": null,
"last_activity_timestamp": "1730000000",
"profile_timestamp": "1729900000",
"last_activity_check": "2025-03-24T12:00:00.000Z",
"last_profile_fetch": "2025-03-24T11:58:00.000Z"
}
}latest_event is null when no events are returned from relays; profile is null if that pubkey has no row in user_names after the run.
To re-run activity checks while preserving existing activity data:
docker exec -i nymrank_postgres psql -U nymrank_user -d nymrank < reset-activity-checks.sqlThis sets last_activity_check to NULL while keeping last_activity_timestamp intact.