@openzeppelin/guardian-operator-client
v0.18.3
Published
TypeScript HTTP client for Guardian operator dashboard endpoints
Keywords
Readme
@openzeppelin/guardian-operator-client
TypeScript HTTP client for Guardian operator dashboard endpoints.
Installation
npm install @openzeppelin/guardian-operator-clientSetup
import { GuardianOperatorHttpClient } from '@openzeppelin/guardian-operator-client';
const client = new GuardianOperatorHttpClient({
baseUrl: 'http://localhost:3000',
credentials: 'include',
});Usage
Request A Challenge
const challenge = await client.challenge('0x...');
console.log(challenge.challenge.signingDigest);Verify A Signed Challenge
The package does not talk to wallets or sign challenges. Callers provide the commitment and signature.
const verified = await client.verify({
commitment: '0x...',
signature: '0x...',
});
console.log(verified.operatorId);List Accounts (Paginated)
// Default page size is 50; max is 500.
const page = await client.listAccounts({ limit: 50 });
console.log(page.items[0]?.accountId);
// Resume the next page with the cursor from the previous response.
if (page.nextCursor !== null) {
const next = await client.listAccounts({
limit: 50,
cursor: page.nextCursor,
});
console.log(next.items.length);
}
// Filter by pause state. Omit `paused` for all accounts.
const pausedOnly = await client.listAccounts({ paused: true });
const activeOnly = await client.listAccounts({ paused: false });Fetch One Account
getAccount() returns the bare DashboardAccountDetail — there is no
{ success, account } wrapper. Read fields directly on the response.
const detail = await client.getAccount('0x...');
console.log(detail.authorizedSignerIds);
console.log(detail.currentCommitment, detail.stateUpdatedAt);Decoded Account Snapshot
Returns the decoded state (vault contents, pending-candidate flag) at the commitment Guardian last canonicalized for the account. Sourced from Guardian's stored state — no live Miden RPC calls.
const snapshot = await client.getAccountSnapshot('0x...');
console.log(snapshot.commitment, snapshot.updatedAt);
if (snapshot.hasPendingCandidate) {
console.warn('vault may be stale — candidate delta in flight');
}
for (const asset of snapshot.vault.fungible) {
console.log(asset.faucetId, asset.amount);
}Inventory And Health Snapshot
const info = await client.getDashboardInfo();
console.log(info.totalAccountCount, info.environment);
console.log(info.deltaStatusCounts.candidate);
console.log(info.inFlightProposalCount);
if (info.serviceStatus === 'degraded') {
console.warn('degraded aggregates:', info.degradedAggregates);
}info.backend.releaseSweep reports the chain-driven release sweep settings
(rotationSeconds, maxRatePerSecond, recheckSeconds, confirmations).
It is null when the sweep is disabled and absent on servers that predate it.
Aggregate Stats (Assets Under Guard)
One request replaces the full account-list walk plus per-account
snapshot reads (issue #371). The server maintains the aggregate in the
background; asOf tells you how old it is.
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const stats = await client.getDashboardStats({ updatedSince: sevenDaysAgo });
console.log(stats.asOf, stats.accounts.total, stats.accounts.byLifecycle);
console.log(stats.accounts.updatedWithin7d, stats.accounts.byAuthMethod);
for (const shape of stats.accounts.byAuthMethodAndSignerCount) {
console.log(shape.authMethod, shape.authorizedSignerCount, shape.count);
}
// Asset totals cover Miden accounts whose metadata `updatedAt` is on or
// after `updatedSince`; account counts are always unfiltered.
for (const total of stats.assets.fungible) {
console.log(total.faucetId, BigInt(total.totalAmount)); // base units, may exceed 2^53
}
if (!stats.assets.complete) {
console.warn('partial coverage:', stats.assets.covered, 'of', stats.assets.eligible, stats.assets.skipped);
}The aggregate is one snapshot per fleet: the holder of the dashboard_stats
lease walks the inventory every GUARDIAN_DASHBOARD_STATS_REFRESH_INTERVAL_SECS
(300 s by default) and every replica serves the same version. Until the first
publication the call throws a GuardianOperatorHttpError with code
data_unavailable (503, retryable); a malformed updatedSince yields
invalid_timestamp (400). asOf is the only truthful age signal — a slow or
failed walk keeps the previous publication.
Operators holding stats:refresh can ask for an out-of-cycle walk:
const refresh = await client.requestDashboardStatsRefresh(); // 202
console.log(refresh.status, refresh.requestedAt ?? refresh.startedAt, refresh.currentAsOf);
// 'queued' (recorded for the lease holder, also when one was already pending)
// or 'in_progress' (a walk is running; nothing duplicated). Inside the
// 60-second cooldown the call throws `rate_limit_exceeded` (429) with
// `retryAfterSecs` set. Poll getDashboardStats() for a newer asOf/version.Per-Account Delta Feed
Each entry carries the dashboard-ready activity fields spread directly
at L1 (feature 007). category is the server-curated coarse
classification; assets (array) / counterparty / noteCounts are
derived from the on-chain TransactionSummary; proposalType echoes
the operator's intent label for multisig commits. All five fields are
optional — absent fields mean "not derivable from this row" (EVM
deltas, pre-feature-007 historical rows whose proposal was already
deleted, or undecodable payloads).
const page = await client.listAccountDeltas('0x...', { limit: 50 });
for (const entry of page.items) {
console.log(entry.nonce, entry.status, entry.statusTimestamp);
if (entry.category) {
console.log('category:', entry.category);
console.log('notes:', entry.noteCounts);
if (entry.proposalType) {
console.log('proposal type:', entry.proposalType);
}
if (entry.assets && entry.assets.length > 0) {
for (const a of entry.assets) {
console.log('asset:', a.assetId, a.amount);
}
}
} else {
// No dashboard fields → render as "metadata unavailable", not as
// zero-valued defaults. Reasons listed above.
console.log('metadata unavailable');
}
}For the full structured projection of a single delta (decoded input /
output notes, vault changes, storage changes, plus the full
proposal intent block), call getAccountDeltaDetail:
const detail = await client.getAccountDeltaDetail('0x...', entry.nonce);
console.log('input notes:', detail.inputNotes.length);
console.log('vault changes:', detail.vaultChanges);
if (detail.proposal) {
console.log('proposal intent:', detail.proposal);
}
// Debug: include the raw base64 TransactionSummary blob in the
// response so you can sanity-check the server's projection against
// the on-disk bytes.
const debug = await client.getAccountDeltaDetail('0x...', entry.nonce, {
includeRaw: true,
});
console.log('raw bytes (base64):', debug.rawTransactionSummary);Per-Account In-Flight Proposals
Single-key Miden accounts and EVM accounts always return an empty proposal queue.
Send proposals carry their P2ID fields on the entry metadata. noteType
is the note's visibility, absent meaning public (issue #322).
reclaimHeight and timelockHeight are optional P2IDE constraints
(issue #366): the presence of either means the proposal creates a P2IDE
note rather than a plain P2ID one, so a dashboard should surface them as
spend conditions rather than hiding them.
const page = await client.listAccountProposals('0x...', { limit: 50 });
for (const entry of page.items) {
console.log(
entry.commitment,
entry.signaturesCollected,
'/',
entry.signaturesRequired,
);
}Pause / Unpause Account
Both endpoints require the accounts:pause permission and are
idempotent. Re-pausing an already-paused account returns success with
the original pausedAt and pausedReason preserved (first-writer
wins). reason is required on pause (non-empty, ≤ 512 chars) and
optional on unpause.
const paused = await client.pauseAccount('0x...', 'compliance review');
console.log(paused.pausedAt, paused.pausedReason);
await client.unpauseAccount('0x...', 'cleared by legal');While an account is paused, mutating endpoints (delta submission,
proposal creation, proposal signing) reject with HTTP 409 and code
account_paused (normalized by the client from the server wire code
GUARDIAN_ACCOUNT_PAUSED). Account setup paths (configureAccount,
EVM register) are admin operations and remain available while paused.
Read endpoints — including listAccounts({ paused: true }) and
getAccount() — continue to work and surface pausedAt /
pausedReason on the response.
Cross-Account Delta Feed
Paginated newest-first by status_timestamp DESC. The optional
status filter accepts a single status or an array; the wrapper
serializes the array to a comma-separated query parameter.
const page = await client.listGlobalDeltas({
limit: 50,
status: ['candidate', 'canonical'],
});
for (const entry of page.items) {
console.log(entry.accountId, entry.nonce, entry.status);
}Cross-Account In-Flight Proposal Feed
Paginated newest-first by originating_timestamp DESC. There is no
status filter — every entry is in-flight by definition. EVM accounts
do not appear in v1.
const page = await client.listGlobalProposals({ limit: 50 });
for (const entry of page.items) {
console.log(
entry.accountId,
entry.commitment,
entry.signaturesCollected,
'/',
entry.signaturesRequired,
);
}Logout
await client.logout();Pagination Shape
Every paginated dashboard endpoint returns a PagedResult<T> envelope:
{
"items": [ /* T entries */ ],
"next_cursor": "string | null" // null at end of list
}limitdefaults to50and is capped at500. A bare?limit=(present but empty) is treated as omitted; out-of-range values return400 invalid_limit.cursoris opaque, server-signed, and tied to a specific endpoint kind (e.g. an account-list cursor cannot be replayed on the deltas endpoint). Tampered or stale cursors return400 invalid_cursor. There is no client-visible TTL.- A
cursormay be supplied without alimit; the default of 50 applies. - The account list endpoint sorts by
(updated_at DESC, account_id ASC). Concurrent updates toupdated_atMAY cause an account to be skipped or repeated across a traversal — documented expected behavior of cursor pagination over a mutable sort key. All other paginated endpoints sort by an immutable per-accountnonce(or(nonce, commitment)for proposals) and are fully stable.
Cookie Transport
The Guardian operator session is cookie-based. This package does not manage a
cookie jar. Configure credentials or a custom fetch implementation
appropriate for your runtime.
Error Handling
import {
GuardianOperatorContractError,
GuardianOperatorHttpError,
isDashboardErrorCode,
} from '@openzeppelin/guardian-operator-client';
try {
await client.listAccounts({ limit: 9999 });
} catch (error) {
if (error instanceof GuardianOperatorHttpError) {
// Stable machine-readable code lives on `error.data.code`.
// Branch on it rather than on `error.status` or
// `error.data.error` (the human message).
const code = error.data?.code;
if (code && isDashboardErrorCode(code)) {
switch (code) {
case 'invalid_limit':
case 'invalid_cursor':
case 'invalid_status_filter':
// Caller bug — fix the request.
break;
case 'account_not_found':
// Path-addressed account does not exist.
break;
case 'data_unavailable':
// Metadata exists but storage cannot be read; retry later.
break;
case 'authentication_failed':
// Operator session is missing, tampered, or expired —
// re-issue the challenge / verify flow.
break;
}
}
}
if (error instanceof GuardianOperatorContractError) {
console.error(error.message);
}
}The dashboard error taxonomy (feature 005-operator-dashboard-metrics
FR-028) is:
| HTTP | Body code | When |
|------|-------------|------|
| 401 | authentication_failed | missing / tampered / expired operator session |
| 403 | GUARDIAN_INSUFFICIENT_OPERATOR_PERMISSION | operator session is valid but lacks one or more route-required permissions (feature 006-operator-authz) |
| 404 | account_not_found | path-addressed account does not exist |
| 400 | invalid_cursor | tampered, malformed, or stale cursor |
| 400 | invalid_limit | limit outside [1, 500] |
| 400 | invalid_status_filter | global delta feed status filter is unknown or malformed |
| 409 | account_paused | mutating call against a paused account; error details carry pausedAt and pausedReason. Wire code GUARDIAN_ACCOUNT_PAUSED is normalized by the client. |
| 503 | data_unavailable | metadata exists but underlying records cannot be read |
Operator Authorization (feature 006-operator-authz)
The dashboard now enforces per-operator permissions on every route. The
allowlist JSON consumed by GUARDIAN_OPERATOR_PUBLIC_KEYS_JSON /
_FILE / _SECRET_ID accepts a heterogeneous array of two element
shapes:
[
// Legacy element: bare hex string → `{dashboard:read}` only.
"0x094f145ec43583db3ca443f43a67545c...",
// Structured element: explicit permission set.
{
"public_key": "0x0944089753530e9104cc9fc4...",
"permissions": ["dashboard:read", "accounts:pause"]
},
// Explicit-deny element: loads, but denies every authorization
// check (different from omitting the operator entirely).
{
"public_key": "0x0a11...",
"permissions": []
}
]Recognized permissions in v1: dashboard:read, accounts:pause,
policies:write, stats:refresh (issue #371: requestDashboardStatsRefresh()).
When the server denies a request for insufficient permissions, the response body extends the existing flat envelope additively:
{
"success": false,
"code": "GUARDIAN_INSUFFICIENT_OPERATOR_PERMISSION",
"error": "Operator lacks required permissions: accounts:pause",
"missing_permissions": ["accounts:pause"],
"retryable": false
}parseErrorBody and the GuardianOperatorHttpErrorData shape both
expose missingPermissions and retryable for this code; the fields
are undefined for every other code.
For UI gating, call getSession() to read the authenticated
operator's live permission set and compare entries against the
exported wire-string constants:
import {
ACCOUNTS_PAUSE,
DASHBOARD_READ,
GuardianOperatorHttpClient,
} from '@openzeppelin/guardian-operator-client';
const client = new GuardianOperatorHttpClient('https://guardian.example');
const { permissions } = await client.getSession();
const canPause = permissions.includes(ACCOUNTS_PAUSE);
const canRead = permissions.includes(DASHBOARD_READ);The session endpoint re-reads permissions from the allowlist on every call, so allowlist edits take effect without re-login. The server is still the source of truth on every mutating endpoint — the client MUST NOT short-circuit a request based on its own capability check.
