From df170475459f3f602336e9a7a7a0fb244a66c70b Mon Sep 17 00:00:00 2001 From: sstefdev Date: Fri, 22 May 2026 19:49:42 +0200 Subject: [PATCH] feat(api): GET /v1/attestor-sets/by-member/{pubkey} for member discovery --- CHANGELOG.md | 1 + Cargo.lock | 1 + README.md | 1 + crates/api/Cargo.toml | 6 +++ crates/api/src/handlers.rs | 97 ++++++++++++++++++++++++++++++++++++++ crates/api/src/main.rs | 7 +++ crates/api/src/queries.rs | 71 ++++++++++++++++++++++++++++ 7 files changed, 184 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index de6e119..2f1f8d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Format follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/). I ### Added +- `GET /v1/attestor-sets/by-member/{pubkey}` — paginated list of attestor sets whose `members` JSONB array contains the given bech32m `lpk1...` pubkey. Same `Page` envelope and `(registered_at_slot, id)` cursor as `/v1/attestor-sets`, so dashboard clients reuse the existing pagination plumbing. Uses the GIN index on `attestor_sets.members` (already present from the original indexer migration) via the JSONB `@>` operator, so the WHERE is an index seek not a full table scan. `schema_count` is read from the denormalised column the indexer already maintains, not recomputed via a per-row LEFT JOIN. Path-param `pubkey` is bech32m-validated (HRP `lpk` + 32-byte payload); typos return 400 `invalid_pubkey` instead of an opaque empty 200. Empty memberships return 200 with `data: []` (absence is a valid answer, not a missing resource). Powers the themisra-dashboard Settings panel's "you're a member of N sets" view (closes audit gap #4 from `ligate-io/themisra-dashboard#34`). - Three activity-leaderboard endpoints under `/v1/stats/`: - `GET /top-attesters?n=10` — addresses ranked by attestation submissions (`attestations.submitter`). All-time count. - `GET /top-schema-owners?n=10` — addresses ranked by schemas registered (`schemas.owner`). All-time count. diff --git a/Cargo.lock b/Cargo.lock index fed39c9..91a4e38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3951,6 +3951,7 @@ dependencies = [ "anyhow", "axum", "base64 0.22.1", + "bech32", "chrono", "dashmap 6.1.0", "hex", diff --git a/README.md b/README.md index 32895ba..e2de875 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ GET /v1/schemas/{id}/attestations → attestations for one schema # Attestor sets GET /v1/attestor-sets → list of registered attestor sets +GET /v1/attestor-sets/by-member/{pubkey} → attestor sets that include this `lpk1...` member GET /v1/attestor-sets/{id} → attestor-set detail GET /v1/attestor-sets/{id}/attestations → attestations for one attestor set diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index 9f37b6c..65e61b3 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -47,6 +47,12 @@ chrono.workspace = true # Hex (for token id parsing during boot) hex.workspace = true +# Bech32m. Used by handler-side input validation for `lpk1...` pubkey +# path params on `/v1/attestor-sets/by-member/{pubkey}` so typos +# return 400 instead of an opaque empty 200. Same crate the indexer +# uses to derive `AttestationId`; shared workspace pin. +bech32.workspace = true + # Base64url codec for opaque list-endpoint cursors (RFC 0001 pagination). base64.workspace = true diff --git a/crates/api/src/handlers.rs b/crates/api/src/handlers.rs index 88c98e9..50cc2cc 100644 --- a/crates/api/src/handlers.rs +++ b/crates/api/src/handlers.rs @@ -1247,6 +1247,103 @@ pub async fn attestor_sets_list( ) } +/// `GET /v1/attestor-sets/by-member/{pubkey}` — paginated list of +/// attestor sets whose `members` array contains the given bech32m +/// `lpk1...` pubkey. Same `Page` envelope, +/// `(registered_at_slot, id)` cursor, and TTL as `/v1/attestor-sets`, +/// so dashboard clients can reuse the same pagination plumbing. +/// +/// Empty list is a 200, not a 404 — the absence of memberships is a +/// valid answer ("you're not in any sets yet"), not a missing +/// resource. 400 on a malformed pubkey path param. +/// +/// Powers the themisra-dashboard Settings panel's "you're a member +/// of N sets" view (closes audit gap #4 from +/// `ligate-io/themisra-dashboard#34`). +pub async fn attestor_sets_by_member( + State(state): State, + Path(pubkey): Path, + Query(params): Query, +) -> impl IntoResponse { + // Validate bech32m HRP `lpk` + 32-byte payload inline. Inlined + // rather than factored into a helper because returning the heavy + // `Response` type out of a small function trips + // `clippy::result_large_err`; the early-return pattern matches + // what `attestation_by_id` does for its `lat1...` validation. + let pubkey = pubkey.trim().to_lowercase(); + match bech32::decode(&pubkey) { + Ok((hrp, data)) if hrp.as_str() == "lpk" && data.len() == 32 => {} + Ok((hrp, data)) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_pubkey", + "detail": format!( + "expected bech32m HRP `lpk` with 32-byte payload, got HRP `{}` with {}-byte payload", + hrp.as_str(), + data.len(), + ), + })), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_pubkey", + "detail": format!("bech32m decode failed: {e}"), + })), + ) + .into_response(); + } + } + let limit = cursor::resolve_limit(params.limit); + let before = params + .before + .as_deref() + .and_then(cursor::decode::) + .map(|c| queries::AttestorSetsCursor { + registered_at_slot: c.slot as i64, + id: c.id, + }); + let limit_plus_one = (limit as i64) + 1; + let mut rows = + match queries::attestor_sets_for_member_page(&state.pg, &pubkey, before, limit_plus_one) + .await + { + Ok(rs) => rs, + Err(e) => { + tracing::error!(error = %e, %pubkey, "attestor_sets_for_member_page"); + return internal_error(); + } + }; + let has_more = rows.len() as i64 > limit as i64; + if has_more { + rows.truncate(limit as usize); + } + let next = if has_more { + rows.last().and_then(|r| { + cursor::encode(&AttestorSetsCursor { + slot: r.registered_at_slot as u64, + id: r.id.clone(), + }) + .ok() + }) + } else { + None + }; + let data: Vec = + rows.into_iter().map(attestor_set_row_to_response).collect(); + cached( + Json(Page { + data, + pagination: Pagination { next, limit }, + }), + TTL_SLOW_SECS, + ) +} + fn attestor_set_row_to_response(row: queries::AttestorSetRow) -> AttestorSetResponse { let members: Vec = match row.members { serde_json::Value::Array(arr) => arr diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs index 1b62e2d..f917475 100644 --- a/crates/api/src/main.rs +++ b/crates/api/src/main.rs @@ -249,6 +249,13 @@ async fn main() -> Result<()> { .route("/v1/schemas", get(handlers::schemas_list)) .route("/v1/schemas/{id}", get(handlers::schema_by_id)) .route("/v1/attestor-sets", get(handlers::attestor_sets_list)) + // `/by-member/{pubkey}` registered before the `{id}` route. axum + // disambiguates by segment count (two segments vs one), so order + // is for human readability, not correctness. + .route( + "/v1/attestor-sets/by-member/{pubkey}", + get(handlers::attestor_sets_by_member), + ) .route("/v1/attestor-sets/{id}", get(handlers::attestor_set_by_id)) .route( "/v1/attestor-sets/{id}/attestations", diff --git a/crates/api/src/queries.rs b/crates/api/src/queries.rs index af4e786..06990b5 100644 --- a/crates/api/src/queries.rs +++ b/crates/api/src/queries.rs @@ -992,6 +992,77 @@ pub struct AttestorSetsCursor { pub id: String, } +/// Read a page of attestor sets whose `members` JSONB array contains +/// `pubkey` (bech32m `lpk1...`). Same `(registered_at_slot, id)` DESC +/// ordering and cursor shape as [`attestor_sets_page`], so the wire +/// envelope is identical and the handler can reuse the same encode/ +/// decode + truncation logic. +/// +/// Containment is checked with the JSONB `@>` operator against a +/// single-element array — Postgres uses the GIN index on +/// `attestor_sets.members` (migration +/// `20260509000001_indexer_query_tables.sql:174-176`) so the WHERE +/// is an index seek, not a full table scan. +pub async fn attestor_sets_for_member_page( + pool: &PgPool, + pubkey: &str, + before: Option, + limit_plus_one: i64, +) -> sqlx::Result> { + // `members @> [pubkey]` is the JSONB containment query the GIN + // index serves. Bind via serde_json::Value so sqlx encodes it + // as JSONB, not text. + let member_filter = serde_json::json!([pubkey]); + #[allow(clippy::type_complexity)] + let rows: Vec<(String, Value, i32, i64, String, DateTime, i32)> = match before { + Some(c) => { + sqlx::query_as( + "SELECT id, members, threshold, + registered_at_slot, registered_at_tx, registered_at_timestamp, + schema_count + FROM attestor_sets + WHERE members @> $1 + AND (registered_at_slot, id) < ($2, $3) + ORDER BY registered_at_slot DESC, id DESC + LIMIT $4", + ) + .bind(&member_filter) + .bind(c.registered_at_slot) + .bind(&c.id) + .bind(limit_plus_one) + .fetch_all(pool) + .await? + } + None => { + sqlx::query_as( + "SELECT id, members, threshold, + registered_at_slot, registered_at_tx, registered_at_timestamp, + schema_count + FROM attestor_sets + WHERE members @> $1 + ORDER BY registered_at_slot DESC, id DESC + LIMIT $2", + ) + .bind(&member_filter) + .bind(limit_plus_one) + .fetch_all(pool) + .await? + } + }; + Ok(rows + .into_iter() + .map(|t| AttestorSetRow { + id: t.0, + members: t.1, + threshold: t.2, + registered_at_slot: t.3, + registered_at_tx: t.4, + registered_at_timestamp: t.5, + schema_count: t.6, + }) + .collect()) +} + /// Read a page of attestor sets, descending by /// `(registered_at_slot, id)`. Companion to the existing /// [`attestor_set_by_id`] for the per-id case.