The GuildPass SDK is designed to be lightweight, modular, and easy to extend.
The main entry point. It orchestrates the various services and holds the configuration.
A wrapper around the native fetch API. It handles:
- Base URL management
- API Key injection
- Timeout handling
- Error normalization
- JSON parsing
Each service corresponds to a specific domain of the GuildPass protocol:
- AccessService: Handles
/accessendpoints. - MembershipService: Handles
/membershipendpoints. - RolesService: Handles
/guilds/:id/rolesendpoints. - GuildsService: Handles
/guildsconfiguration endpoints.
Designed for future on-chain support. Currently provides stubs and validation patterns for:
- Token balance checks
- On-chain role requirement validation
- Guild ownership lookup
All on-chain reads flow through a pluggable ContractProvider abstraction (see below).
When rpcUrls (or chains[chainId].rpcUrls) is configured with multiple endpoints, the SDK
automatically fails over across them on transient errors. This is implemented inside
JsonRpcContractProvider, which tries each URL in sequence:
- The primary URL (
rpcUrlor the first entry inrpcUrls) is attempted first. - On a transient error (network failure, 429 rate-limit, 5xx server error, timeout) the provider moves on to the next URL without surfacing the error to the caller.
- On a non-transient error (contract revert, invalid parameters, malformed response) the error is propagated immediately — there is no point retrying a different node for a contract-level failure.
- When all URLs are exhausted with transient errors, the last error is thrown.
Failover vs retry ordering: Failover and retry are independent layers with failover running inside each retry attempt. Concretely:
- If
retry.maxRetriesis configured,HttpClientretries the same URL with exponential backoff first. - When HttpClient gives up (max retries hit or a non-retryable error), the error propagates
up to
JsonRpcContractProvider, which then moves to the next RPC URL. - Retry counters reset for each new URL — the next URL gets its own full set of retry attempts.
This means the upper-bound latency is:
(N_rpc_urls) × (1 + maxRetries) × (timeoutMs + maxDelayMs)
For example, with 3 RPC URLs, retry.maxRetries = 2, timeoutMs = 10_000, and
retry.maxDelayMs = 5_000, the upper bound is 3 × 3 × 15_000 = 135_000ms.
Observability: Configure the onRpcFailover hook to get notified each time the SDK
switches endpoints:
const client = new GuildPassClient({
apiUrl: '...',
rpcUrls: ['https://rpc1.example', 'https://rpc2.example'],
hooks: {
onRpcFailover: ({ chainId, failedUrl, nextUrl, error }) => {
console.warn(`RPC failover on chain ${chainId}: ${failedUrl} → ${nextUrl}`);
},
},
});Hook failures are silently caught and never affect the failover flow.
ContractClient never talks to a chain directly. Instead, every read goes through the
ContractProvider interface (src/contracts/providers/provider.types.ts):
interface ContractProvider {
ethCall(request: { to: string; data: string }, options?: RequestOptions): Promise<unknown>;
batchEthCall(requests: EthCallRequest[], options?: RequestOptions): Promise<BatchItemResult[]>;
}Provider resolution (per call):
- If
GuildPassClientConfig.contractProvideris set, it is used for every contract read and takes precedence overrpcUrl(including per-chainchains[].rpcUrl). - Otherwise the default
JsonRpcContractProvideris constructed from the resolvedrpcUrl— the original raw JSON-RPC-over-fetch behavior, unchanged. - If neither is available, the call fails fast with
INVALID_CONFIG("rpcUrl is required for contract calls"), exactly as before.
Responsibility split: ContractClient owns input validation, chain/contract-address
resolution, batch size limits/chunking, and result decoding. Providers own only transport —
"make an eth_call reach a chain". This keeps error semantics uniform: provider-level
failures are always HTTP_ERROR, undecodable results are always INVALID_RESPONSE,
regardless of which provider is in use.
Adapters for viem and ethers live in dedicated subpath exports so they are only ever bundled when explicitly imported:
@guildpass/sdk/adapters/viem→viemContractProvider(publicClient)@guildpass/sdk/adapters/ethers→ethersContractProvider(provider)
The adapters are structurally typed — they accept anything with a compatible call()
method and never import viem or ethers. Both libraries are optional peer dependencies
only; the core package stays zero-runtime-dependency, and consumers who don't import the
adapter subpaths see no bundle-size increase.
The opt-in contractReadConsensus config adds a second, complementary guarantee on top
of rpcUrls failover: not just availability (the primary URL answers) but also
correctness (the answer matches what other independent endpoints reported).
const client = new GuildPassClient({
apiUrl: 'https://api.guildpass.xyz',
contractAddress: '0x...',
contractReadConsensus: {
providers: [
'https://rpc-a.example', // independently operated RPCs
'https://rpc-b.example', // (typically run by different infra providers)
'https://rpc-c.example',
],
minProviders: 2, // at least 2 must agree
},
});Why this matters. Multi-RPC failover (see ### 5. RPC Failover) only catches
responses that never arrive, not responses that arrive with a wrong-but-well-formed
payload. A compromised, misconfigured, or malicious RPC returning a fabricated balance
could grant (or deny) access without the SDK noticing, because the JSON-RPC reply was
structurally valid. Consensus mode forces cross-provider agreement on the raw hex
response before the SDK trusts it.
Operation. When contractReadConsensus is set, every supported single-call read
(getMembershipTokenBalance, getERC20Balance, ownsERC721Token,
getERC1155Balance, getGuildOwner, readContract) is fanned out to every URL in
providers in parallel via Promise.allSettled. The successful results are grouped
by raw-hex equality (case-insensitive, leading-zero-insensitive) and the SDK returns
the largest group's value iff its size is ≥ minProviders. Anything else surfaces as
GuildPassError with code === CONSENSUS_MISMATCH and a structured details payload
listing every disagreeing provider, the raw values each returned, and any per-provider
failures (network errors, reverts) so operators can attribute the lie.
Batch reads (batchEthCall, getMembershipTokenBalancesBatch,
getGuildOwnersBatch) follow the same precedence chain (custom contractProvider →
consensus → default) and apply the same consensus logic but per item. Each index of a
batch ballots its N raw hex results independently: a successful index returns the
front-runner value when ≥ minProviders agree; otherwise the index becomes
{ status: 'error', error: 'Consensus mismatch at batch index i: ...' } instead of a
throw. A batch never throws for item-level disagreement — only when every provider
rejected the batch outright (no per-item ballot to attribute) does the SDK surface a
batch-level CONSENSUS_MISMATCH. Chunked batches
(getMembershipTokenBalancesBatch({ chunk: true })) run the per-item quorum on each
chunk sequentially, so a 200-wallet input at maxBatchSize=50 produces 4 sequential
consensus ballots preserving v1's chunked ordering guarantees.
validateRoleRequirement routes every internal eth_call (ERC-165 supportsInterface,
ERC-20 balanceOf, ERC-721 ownerOf, AccessControl hasRole) through the same
resolveSingleEthCall precedence chain used by the convenience methods, so TOKEN/NFT/ROLE
on-chain requirements benefit from consensus automatically. Each individual call (one
or more per requirement) holds the quorum; if any of them fails to meet it, the SDK
surfaces a single-call CONSENSUS_MISMATCH so the requirement fails-closed.
Precedence. Consensus sits in this order, top wins:
contractProvider(custom viem/ethers adapter) — bypasses consensus entirely.contractReadConsensus— fans out acrossconsensus.providers.- The default JSON-RPC/Multicall3 + failover path.
So consumers who wrap an existing RPC infrastructure retain their custom transport; consumers
who only need correctness opt in by listing multiple public endpoints. The two features
are orthogonal — contractReadConsensus.providers and the failover rpcUrls list are
deliberately separate arrays so a single endpoint exhaustion in failover cannot mask a
disagreement in consensus.
Block tag. Consensus-mode reads always use 'latest' — a quorum over historical
confirmations-based reads would require out-of-band block-height agreement across
providers, which is reserved for a future enhancement. Verify on receipt: the parallel
providers should observe the same logical head block modulo a few-second chain tip
differences between providers, which is exactly what we want for fast token-gating
checks.
Cancellation. A caller-provided AbortSignal is honoured: any provider that
rejected with REQUEST_CANCELLED / ABORTED is re-thrown immediately rather than
folded into a generic mismatch — the user explicitly asked to cancel, and the SDK
respects that before running any consensus math.
Multicall3 collision. Setting batchStrategy: 'multicall3' AND
contractReadConsensus together is rejected with INVALID_CONFIG at the call site.
Multicall3 collapses multiple calls into a single on-chain transaction per provider,
which means an attacker that controls the provider's view of Multicall3 can dictate
every item's result and defeat cross-provider verification. Disable one of the two —
the SDK surfaces an error rather than silently picking whichever you probably didn't
intend.
Validation. contractReadConsensus is enforced at construction time by
validateConfig in src/config/sdkConfig.ts:
providersmust be a non-empty array of unique http(s) URLs (duplicates are rejected because they would inflate the apparent agreeing count from one physical endpoint).minProvidersmust be an integer in[2, providers.length]. A quorum of 1 has no majority-over-lying-RPC value, so it is explicitly rejected.
Failure-mode code-style error details. When the throw happens, the resulting
GuildPassError.details carries:
{
totalProviders: number, // length of consensus.providers
successfulCount: number, // providers that returned a usable raw hex
failedCount: number, // providers that errored out
quorum: number, // minProviders the SDK was configured to require
groups: [
{ value: '0x...', urls: ['url1', 'url2'], count: 2 }, // distinct values
...
],
failures: [
{ url: 'urlN', code: 'HTTP_ERROR', message: 'execution reverted' },
...
],
}This lets operators identify the lying endpoint at a glance — no forensic cross-referencing of logs.
The SDK offers two strategies for executing batch eth_call contract reads:
-
JSON-RPC Batching (
'jsonrpc')- How it works: Sends a raw JSON-RPC array containing multiple
eth_callrequests inside a single HTTP POST payload. - Pros: Simple, does not require any on-chain smart contract deployments (completely client-side aggregation).
- Cons: Many public or free-tier RPC providers (e.g. Infura on certain tiers, Cloudflare, or local rate-limited endpoints) throttle, disable, or return truncated arrays/single errors for JSON-RPC batch requests.
- When to prefer: On private or paid RPC endpoints that explicitly support large JSON-RPC array payloads, and where you want to avoid overhead/gas associated with an on-chain read.
- How it works: Sends a raw JSON-RPC array containing multiple
-
Multicall3 Aggregation (
'multicall3')- How it works: ABI-encodes the individual batch calls into a single call to
Multicall3.aggregate3(Call3[] calldata calls)on-chain, targetting the canonical Multicall3 contract (0xcA11bde05977b3631167028862bE2a173976CA11). The provider sends this as a single standardeth_callrequest, then decodes the returnedResult[]back into individual results. - Pros: Bypasses HTTP JSON-RPC batch limits/throttles entirely. Guarantees atomic/consistent reads from the same block, and isolates failures on a per-call basis via
allowFailure: true. - Cons: Requires the canonical Multicall3 contract to be deployed on the target chain (which is true for almost all public EVM networks).
- When to prefer: On public, free, or shared RPC endpoints to prevent silent batch truncation or HTTP throttling failures.
- How it works: ABI-encodes the individual batch calls into a single call to
The default strategy is 'jsonrpc' to maintain backwards compatibility. You can opt in globally or override it:
const client = new GuildPassClient({
apiUrl: '...',
rpcUrl: 'https://...',
batchStrategy: 'multicall3', // Opt into Multicall3 globally
});You can also override the Multicall3 contract address on custom chains:
const client = new GuildPassClient({
apiUrl: '...',
batchStrategy: 'multicall3',
chains: {
1337: {
rpcUrl: 'https://localhost:8545',
multicallAddress: '0xCustomMulticallAddress...',
},
},
});The SDK includes a resilient caching layer that wraps service methods.
- CacheAdapter: An interface for implementing custom cache backends (e.g., Redis).
- InMemoryCacheAdapter: A default, zero-dependency in-memory cache.
- Resilience: Caching is non-blocking and failure-tolerant. Cache errors are isolated from the main request flow.
- Observability: Developers can monitor cache health via lifecycle hooks.
- Developer initializes
GuildPassClientwith an optionalcache. - Developer calls a method on a service (e.g.,
client.access.checkAccess). - If caching is enabled:
- The SDK attempts to retrieve the value from the
cache. - If successful (cache hit), the value is returned immediately.
- If a cache failure occurs, the SDK logs the error via hooks and proceeds to the network.
- The SDK attempts to retrieve the value from the
- Service validates input using
src/utils/validation.ts. - Service calls
HttpClientwith the appropriate path and params. HttpClientexecutes the fetch request.- If successful:
- The SDK attempts to store the result in the
cache. - If a cache failure occurs, the SDK logs the error via hooks and returns the response.
- The SDK attempts to store the result in the
- If the request fails, a
GuildPassErroris thrown with a specificGuildPassErrorCode. - The typed response is returned to the developer.
- Zero External Dependencies: The SDK relies on native platform features (like
fetch,AbortController, andWebSocket) to keep the bundle size small.
To prevent intermediaries from tampering with access decisions or guild metadata, the SDK provides an opt-in signature verification feature.
When verifySignedResponses: true and a trustedSignerAddress is configured, the SDK requires API endpoints for access checks and guild configuration to return a cryptographically signed envelope:
{
"data": { ...original payload... },
"signature": "0x...",
"signer": "0x..."
}The SDK canonicalizes the data payload via standard JSON stringification and verifies the ECDSA signature against the configured trustedSignerAddress using either viem or ethers (which must be installed if this feature is used). If verification fails, a GuildPassError with code UNVERIFIABLE_RESPONSE is thrown.
- Strong Typing: Everything is typed with TypeScript for the best developer experience.
- Fail Fast: Input validation happens before network requests.
- Environment Agnostic: Works in Node.js (18+), Browsers, and Edge runtimes.
- Optional Advanced Features: Real-time event subscriptions via
WebSocketContractProviderare opt-in and do not affect the default HTTP RPC path.
The SDK targets three runtime environments, each with a distinct API surface. The table below documents which Node.js-specific APIs are used, where they are isolated, and what the Edge-runtime behaviour is.
| Module | Node.js API | Edge alternative | Status |
|---|---|---|---|
src/utils/address.ts |
import { createHash } from 'node:crypto' (SHA3-256) |
Replaced with js-sha3's keccak256 (universal pure-JS) |
✅ Edge-safe |
src/siwe/siwe.helpers.ts (generateSiweNonce) |
require('node:crypto').randomBytes |
globalThis.crypto.getRandomValues (Web Crypto) + Math.random last-resort |
✅ Edge-safe |
src/utils/constantTime.ts |
— | Uses only TextEncoder (universal) |
✅ Edge-safe natively |
src/crypto/secp256k1.ts |
— | Pure bigint arithmetic + js-sha3 |
✅ Edge-safe natively |
src/http/tokenBucket.ts |
— | Uses only setTimeout / Date.now (universal) |
✅ Edge-safe natively |
src/cache/cache.types.ts |
— | Uses only Map + Date.now (universal) |
✅ Edge-safe natively |
src/errors/ |
— | Pure structural types | ✅ Edge-safe natively |
src/validation/schema.ts |
— | Pure runtime type predicates | ✅ Edge-safe natively |
src/http/httpClient.ts |
— | Uses globalThis.fetch (universal) |
✅ Edge-safe natively |
src/contracts/providers/webSocketProvider.ts |
— | Uses WebSocket (universal) |
✅ Edge-safe natively |
src/contracts/providers/jsonRpcProvider.ts |
— | Uses HttpClient (fetch) |
✅ Edge-safe natively |
The SDK provides a shared environment-detection module at src/utils/env.ts with four predicates:
isNodeEnvironment()— true whenglobalThis.process.versions.nodeexists.hasWebCrypto()— true whenglobalThis.crypto.getRandomValuesis a function.isEdgeRuntime()— true when not Node.js, hasaddEventListener, and nonavigator.isBrowser()— true whenwindowanddocumentare defined.
All four functions are safe to call in any environment — they never import platform-specific APIs.
-
generateSiweNonce()crypto quality in exotic environments: When neither Web Crypto nor Node.jscryptomodule is available (e.g., a severely restricted V8 sandbox), the function falls back toMath.random(). This is not cryptographically secure — the function will still produce a valid 16-character nonce, but it should not be relied upon for security in such environments. All mainstream runtimes (Node.js 18+, modern browsers, Cloudflare Workers, Deno, Bun) provide Web Crypto, so this fallback is purely defensive. -
InMemoryNonceStoresweep timer: The optional background sweep interval usessetIntervalwithunref(). Edge runtimes may not supportunref()— this is handled gracefully: the timer simply keeps the event loop alive ifunrefis unavailable. Users ofInMemoryNonceStorein Edge environments should omit thesweepIntervalMsoption or plan for manualsweepExpired()calls.
The full test suite runs in three environments configured via vitest.workspace.ts:
- Node (
vitest environment: 'node'): All tests intests/**/*.test.ts(excludingtests/compat/). - Browser (JSDOM) (
vitest environment: 'jsdom'):tests/compat/browser/**/*.test.ts. - Edge (V8-isolate) (
vitest environment: 'edge-runtime'):tests/compat/edge/**/*.test.ts.
Run all three with:
pnpm test:compat