Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<AttestorSetResponse>` 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.
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions crates/api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
97 changes: 97 additions & 0 deletions crates/api/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AttestorSetResponse>` 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<AppState>,
Path(pubkey): Path<String>,
Query(params): Query<PaginationParams>,
) -> 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::<AttestorSetsCursor>)
.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<AttestorSetResponse> =
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<String> = match row.members {
serde_json::Value::Array(arr) => arr
Expand Down
7 changes: 7 additions & 0 deletions crates/api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
71 changes: 71 additions & 0 deletions crates/api/src/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AttestorSetsCursor>,
limit_plus_one: i64,
) -> sqlx::Result<Vec<AttestorSetRow>> {
// `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<Utc>, 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.
Expand Down
Loading