Skip to content

Dashboard API: Add a bounded /dashboard/stats endpoint for account and Miden vault aggregates #371

Description

@zeljkoX

Problem

The cross-operator guardian-dashboard reports, per Guardian server, account activity and composition plus assets under guard. Guardian does not expose these as a complete aggregate API, so the dashboard reconstructs them client-side:

  • it pages the full account list (GET /dashboard/accounts) to compute accounts updated within 7/30 days, account composition, and paused/released counts (app/api/accounts/stats/route.ts);
  • it calls GET /dashboard/accounts/{id}/snapshot once for every account whose updated_at falls within the last seven days, then aggregates the decoded vault (app/api/accounts/asset-totals/route.ts).

On a Guardian holding 2,379 accounts, this is a full-list walk plus approximately 1,086 snapshot requests per refresh.

The code-default DEV HTTP limits are 10 requests/second burst and 60 requests/minute sustained (GUARDIAN_RATE_BURST_PER_SEC and GUARDIAN_RATE_PER_MIN). Production Terraform currently sets 200/second and 5,000/minute. On a Guardian enforcing the 60/minute sustained budget, operators observed the walk being cut off after approximately 57 requests—the remaining requests in the window having been consumed by authentication and inventory reads—with 429 and Retry-After: 60.

The dashboard compensates with adaptive fetch ceilings and does not publish a new total while snapshot reads remain unattempted because of its ceiling or a 429. This leaves operators seeing states such as:

Assets (7d active): Calculating… 25 of 1,086

Under sufficiently tight limits, completion can take many minutes or may never converge across cold serverless instances. Non-rate-limit snapshot failures are currently treated as attempted and omitted, so the client also lacks authoritative coverage information for deciding whether a total is complete.

Raising rate limits is a per-operator mitigation. The underlying problem is the request volume: only Guardian can aggregate its stored states efficiently and report authoritative coverage.

A related failure already appears at this scale. Above the configured aggregate threshold, /dashboard/info declines to compute accounts_by_auth_method and includes it in degraded_aggregates. On a 2,379-account Guardian, the overview consequently reports that the server cannot compute the account breakdown precisely where server-side aggregation matters most.

Current API surface

Endpoint Provides Gap
GET /dashboard/info total_account_count, accounts_by_auth_method, delta status counts, in-flight proposal count, and latest activity No asset data or activity-window/lifecycle counts; auth-method aggregation degrades above a threshold
GET /dashboard/accounts Paginated account summaries Consumers must walk the inventory to compute cross-account counts
GET /dashboard/accounts/{id}/snapshot One decoded Miden vault Requires one request per account to aggregate assets

Proposal

Add GET /dashboard/stats, protected by the operator session and dashboard:read permission, returning account and asset aggregates in one request.

This issue specifies observable requirements rather than implementation. Guardian may maintain incremental aggregates, serve a periodically refreshed snapshot, or use another bounded mechanism. The existing background-refresher pattern in crates/server/src/metrics/refresher.rs may be useful, but is not required.

Functional requirements

FR-1 — Asset totals

Return totals across eligible Miden account snapshots:

  • fungible base-unit sums grouped by faucet_id;
  • non-fungible asset counts grouped by faucet_id.

Fungible sums must be serialized as base-10 decimal strings and computed without u64 or JavaScript safe-integer overflow.

Guardian must not perform decimals normalization, token pricing, or fiat valuation. Those remain consumer concerns.

FR-2 — Activity filter

Accept an optional RFC3339 updated_since query parameter for the asset aggregation.

For compatibility with the current dashboard, this filter refers to the account metadata updated_at exposed as DashboardAccountSummary.updatedAt, not the stored state row’s timestamp. Accounts are eligible when:

account.updated_at >= updated_since

Omitting the parameter aggregates all accounts.

Invalid timestamps return the standard 400 error envelope with a stable error code.

FR-3 — Account counts

Return the following unfiltered account counts in the same response:

  • total accounts;
  • by mutually exclusive lifecycle state:
    • released when released_at is present;
    • otherwise paused when paused_at is present;
    • otherwise active;
  • by stable auth-method label;
  • by auth-method label and authorized-signer count, so the dashboard can reproduce its current account-shape heuristic without Guardian claiming that the shape identifies a particular client;
  • accounts whose metadata updated_at falls within the last 7 and 30 days, anchored to the response’s as_of.

The endpoint must not silently omit auth-method counts above an inventory threshold.

FR-4 — Coverage and degradation

The response must include:

  • as_of: the timestamp represented by the published aggregate;
  • the applied updated_since, or null;
  • the number of accounts eligible for asset aggregation;
  • the number of accounts covered;
  • the number of accounts skipped, grouped by a stable reason such as unavailable or undecodable state;
  • an explicit complete indicator;
  • stable names for any aggregate the server declined to compute.

Coverage must satisfy:

covered + skipped = eligible

Any skipped eligible account makes the asset aggregate incomplete.

A degraded or unavailable aggregate must be represented explicitly. It must not be serialized as an empty map or zero that could be mistaken for a valid result.

FR-5 — One-call answer

GET /dashboard/stats?updated_since=... must answer the assets-under-guard and account-count questions without per-account follow-up requests.

FR-6 — Bounded serving cost

Serving the endpoint must not decode every eligible account vault on each request. Request-time work must remain bounded independently of inventory size at steady state.

Any background refresh must also be bounded or batched so that a large inventory cannot monopolize server resources. Staleness is acceptable when reported accurately through as_of.

FR-7 — Freshness

The refresh cadence or maximum expected staleness must be documented. The initial target should be no worse than the dashboard’s existing 60-second cache unless maintainers explicitly choose a different budget.

Consumers must be able to determine the age of the result from as_of.

FR-8 — Authentication and errors

  • operator session required;
  • dashboard:read permission required;
  • 401 and 403 behavior identical to existing dashboard read endpoints;
  • standard Guardian error envelope;
  • ordinary HTTP rate limiting applies—one aggregate request replaces approximately 1,100 inventory/snapshot requests.

FR-9 — Existing /dashboard/info degradation

The implementation must prevent the existing accounts_by_auth_method threshold from continuing to report the server as degraded when the new stats aggregate is available.

This may be implemented by reusing the same maintained aggregate in /dashboard/info, or by moving that aggregate authoritatively to /dashboard/stats and updating consumers accordingly. The two endpoints must not report contradictory values.

FR-10 — Contract propagation

Update together:

  • server handler, wire types, and tests;
  • #[utoipa::path] and ToSchema/IntoParams declarations;
  • generated docs/openapi-dashboard.json;
  • packages/guardian-operator-client, including strict response parsing and tests;
  • examples/operator-smoke-web;
  • docs/DASHBOARD.md;
  • relevant behavioral documentation in spec/api.md.

Acceptance criteria

On a Guardian containing approximately 2,400 accounts, including approximately 1,000 whose metadata was updated within seven days:

  1. GET /dashboard/stats?updated_since=<7-days-ago> returns asset totals, coverage, account totals, activity-window counts, lifecycle counts, and auth/account-shape counts.
  2. A dashboard refresh requires at most two Guardian requests: /dashboard/stats and, if still needed, /dashboard/info.
  3. No per-account snapshot requests are required for the overview.
  4. The refresh succeeds under the code-default HTTP rate limits without 429 or a multi-minute calculating state.
  5. Asset totals match a reference full-inventory snapshot aggregation for every covered account.
  6. Missing or undecodable snapshots produce complete: false and explicit skipped coverage; they never appear as valid zero balances.
  7. The operator TypeScript client and operator smoke harness exercise the new endpoint.
  8. Filesystem and Postgres backends produce equivalent response semantics.

Follow-ups

  • Per-account totals in list summaries: the accounts table and CSV still need per-row totals. Adding fungible totals to DashboardAccountSummary or introducing a bulk endpoint should be tracked separately.
  • True client attribution: the dashboard’s “wallet” classification is currently a heuristic based on auth scheme and signer count. A server-recorded client-attribution field at registration would be a separate contract change.

Out of scope

  • Changing the default rate limits.
  • EVM accounts or asset aggregation.
  • Token metadata, decimals normalization, asset pricing, or fiat valuation.
  • Per-account asset totals for the accounts table and CSV.
  • Introducing authoritative wallet/client attribution.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions