npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@openzeppelin/guardian-client

v0.18.0

Published

TypeScript HTTP client for Guardian server

Readme

@openzeppelin/guardian-client

TypeScript HTTP client for Guardian server.

Installation

npm install @openzeppelin/guardian-client

Setup

import { GuardianHttpClient } from '@openzeppelin/guardian-client';

const client = new GuardianHttpClient('http://localhost:3000');

Usage

Get Server Public Key (Unauthenticated)

const pubkey = await client.getPubkey();
console.log('GUARDIAN pubkey:', pubkey);

Set Signer for Authenticated Requests

All endpoints except getPubkey() require authentication. You must provide a signer that implements the Signer interface:

import type { Signer, RequestAuthPayload } from '@openzeppelin/guardian-client';

const signer: Signer = {
  commitment: '0x...', // 64 hex chars
  publicKey: '0x...',  // Full public key hex
  // Sign account ID + timestamp + request payload digest
  signRequest: (accountId: string, timestamp: number, requestPayload: RequestAuthPayload) => {
    // requestPayload is canonicalized by the client before this call
    // implement your signing logic here
    return '0x...';
  },
  signCommitment: (commitmentHex: string) => '0x...', // Returns signature hex
};

client.setSigner(signer);

Configure an Account

await client.configure({
  account_id: '0x...',
  auth: {
    MidenFalconRpo: {
      cosigner_commitments: ['0x...', '0x...'],
    },
  },
  initial_state: { data: '<base64-encoded-account>', account_id: '0x...' },
});

Get Account State

const state = await client.getState(accountId);
console.log('Commitment:', state.commitment);
console.log('State data:', state.state_json.data);

Get Canonical Nonce

Nonce and commitment of the latest canonical state, without the state blob (GET /state/nonce). The full getState fetch can be skipped when Guardian's nonce is below the local account's nonce, or equal to it with the same commitment. An equal nonce at a different commitment means the local account has diverged from Guardian, so fetch the state in that case too. The multisig SDK's syncState() runs this pre-check for you.

const head = await client.getCanonicalNonce(accountId);
const inSync =
  head.nonce < localNonce ||
  (head.nonce === localNonce && head.commitment === localCommitment);
if (!inSync) {
  const state = await client.getState(accountId);
}

Abandon a Stuck Candidate

If an approved transaction died client-side after guardian approval, its candidate keeps the account locked (409 conflict_pending_delta on new proposals). Record an abandon intent and poll for the resolution:

const accepted = await client.abandonCandidate(accountId, nonce);
console.log(accepted.state); // 'pending'

// The guardian's worker confirms over a short quarantine that the tx did
// not land, then releases the account.
const status = await client.abandonStatus(accountId, nonce);
// 'waiting' | 'landed' | 'abandoned' | 'retained' | 'unexpected'

'retained' means the guardian stopped actively verifying the candidate and released the account slot, but the on-chain outcome is still uncertain: background reconciliation may promote the delta to canonical until its retention TTL expires. It is "unlocked but unresolved" — never read it as "the transaction did not land". Sync and check the chain before replacing the slot, since a resubmission supersedes the retained delta and forfeits automatic recovery.

| Status | Account locked? | Outcome known? | Client action | |---|---|---|---| | candidate | Yes | No | Wait, or request abandonment | | retained | No | No | Sync/check chain before replacing | | discarded: client_abandoned | No | Probably not landed; late reconciliation remains possible | Continue cautiously | | canonical | No | Yes — landed | Sync account state |

Look Up An Account By Key Commitment

When a wallet only holds a signing key, it cannot derive the account ID directly. The Guardian server exposes GET /state/lookup so the wallet can ask "which account(s) authorize this commitment?" and proceed with the existing recovery flow.

The signer used here MUST implement signLookupMessage. Raw signers sign the domain-separated LookupAuthMessage::to_word(timestampMs, keyCommitment) digest; EIP-712 signers sign that hash as GuardianLookup(bytes32 lookupHash) and set requestAuthFormat to eip712. The canonical hash implementation lives in @openzeppelin/miden-multisig-client (which has access to the Miden SDK's RPO256); this package keeps the digest computation out of its zero-dependency surface.

const result = await client.lookupAccountByKeyCommitment(keyCommitmentHex);

if (result.accounts.length === 0) {
  console.log('No account authorizes this commitment with this operator.');
} else {
  for (const { accountId } of result.accounts) {
    console.log('Recovered account:', accountId);
    // Continue with the existing /state flow:
    const state = await client.getState(accountId);
    // ... register a new key via the existing delta/proposal flow.
  }
}

For a higher-level helper that composes lookup + state fetch, see recoverByKey in @openzeppelin/miden-multisig-client.

Auth shape

The lookup endpoint accepts the same x-pubkey / x-signature / x-timestamp headers as per-account requests for wire-format consistency, but identity is derived from the signature itself: Falcon signatures embed the public key, ECDSA signatures recover it via the recovery byte. The server then requires the derived key to commit to the queried key_commitment. This means the lookup endpoint works with wallet signers that only expose a 32-byte commitment as publicKey (e.g., the Miden browser wallet) — the signature is what proves possession.

Work with Delta Proposals

// Get all proposals for an account
const proposals = await client.getDeltaProposals(accountId);

// Get one proposal by commitment
const proposal = await client.getDeltaProposal(accountId, '0x...');

// Push a new proposal
const response = await client.pushDeltaProposal({
  account_id: accountId,
  nonce: 1,
  delta_payload: {
    tx_summary: { data: '<base64-tx-summary>' },
    signatures: [],
  },
});

// Sign a proposal
const delta = await client.signDeltaProposal({
  account_id: accountId,
  commitment: response.commitment,
  signature: { scheme: 'falcon', signature: '0x...' },
});

// Execute a proposal
const result = await client.pushDelta({
  account_id: accountId,
  nonce: 1,
  prev_commitment: '0x...',
  delta_payload: { data: '<base64-tx-summary>' },
  status: { status: 'pending', timestamp: '...', proposer_id: '0x...', cosigner_sigs: [] },
});

Get Deltas

// Get specific delta by nonce
const delta = await client.getDelta(accountId, 5);

// Get merged delta since a nonce
const merged = await client.getDeltaSince(accountId, 3);

Delta History

Paginated canonical delta history with server-decoded note summaries (authenticated with the same signed headers as the other per-account reads). Only canonical (confirmed) deltas appear, newest-first by nonce; only transactions pushed through Guardian are visible to it. Served even while the account is paused.

let cursor: string | undefined;
do {
  const page = await client.getDeltaHistory(accountId, { limit: 50, cursor });
  for (const entry of page.entries) {
    // entry.status is 'canonical'; notes carry tag, noteType, assets,
    // sender/recipient where the note script exposes them.
    console.log(entry.nonce, entry.timestamp, entry.outputNotes);
  }
  cursor = page.nextCursor; // undefined when the feed is exhausted
} while (cursor !== undefined);

limit accepts 1–500 (default 50); invalid limits and tampered or cross-account cursors are rejected with invalid_limit / invalid_cursor.

Error Handling

The client throws GuardianHttpError for non-2xx responses:

import { GuardianHttpError } from '@openzeppelin/guardian-client';

try {
  await client.getState(accountId);
} catch (error) {
  if (error instanceof GuardianHttpError) {
    console.error(`HTTP ${error.status}: ${error.statusText}`);
    console.error('Body:', error.body);
  }
}

Branch on error.code, the server's stable machine-readable code, never on the message text. error.meta carries structured side-data for some codes. For example, a Guardian configured with GUARDIAN_ALLOWED_ACCOUNT_SCHEMES rejects a new account whose signature scheme it does not accept:

try {
  await client.configure(request);
} catch (error) {
  if (error instanceof GuardianHttpError && error.code === 'signature_scheme_not_allowed') {
    // HTTP 403, not retryable. Create the account with an accepted scheme.
    console.error(
      `scheme ${error.meta?.scheme} rejected; this Guardian accepts ${error.meta?.allowedSchemes?.join(', ')}`
    );
  }
}

Rate limits and retries

The server rate-limits both its HTTP and gRPC surfaces. The sustained per-minute limit is keyed per IP alone, so HTTP and gRPC calls from one client draw on the same allowance; the burst limit is keyed per IP and endpoint. An over-budget request fails with HTTP 429, code rate_limit_exceeded, and a backoff hint. GuardianHttpError classifies it: isRetryable() reads the error envelope (falling back to the status class), and retryAfterSecs() returns the server's hint, preferring the Retry-After header over the envelope value.

Rate-limit rejections happen before the server touches any state, so retrying them is always safe. The client does not retry rate limits automatically (automatic backoff is tracked in #360); a bounded loop over the exposed hint is a few lines:

async function getStateWithRetry(accountId: string, maxAttempts = 3) {
  for (let attempt = 0; ; attempt++) {
    try {
      return await client.getState(accountId);
    } catch (error) {
      if (
        !(error instanceof GuardianHttpError) ||
        !error.isRetryable() ||
        attempt >= maxAttempts
      ) {
        throw error;
      }
      await new Promise((r) => setTimeout(r, (error.retryAfterSecs() ?? 1) * 1000));
    }
  }
}

Replay-protection retries

Signed requests carry a strictly increasing per-instance timestamp (max(Date.now(), previous + 1)). When a correctly signed request still loses the server's per-signer replay check (stable code authentication_replay, typically two in-flight requests landing out of order), the client retries automatically, up to 2 times with a 50ms backoff, minting a fresh timestamp and signature over the identical payload each attempt. Terminal authentication failures (authentication_failed: clock outside the skew window, invalid or unauthorized signature) are never retried; branch on error.code, never on message text.

The client retries only authentication_replay; it never retries authentication_failed. During a mixed server/client rollout, a replay CAS reported under the older authentication code—or received by a client without replay-specific retry handling—can therefore surface as a terminal 401 until both sides use the same error contract.

The upgraded server also returns the standard { code, message, meta } error envelope for failed HTTP /configure requests instead of a ConfigureResponse with success: false. Direct HTTP integrations that inspect the old error body must migrate with the server rollout.

Testing

npm test           # Run tests once
npm run test:watch # Run tests in watch mode