diff --git a/CHANGELOG.md b/CHANGELOG.md index afd6821..732f504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] ### Added +- **Batch partial-failure entries now carry a `GuildPassErrorCode`** — resolves [#390](https://github.com/Adamantine-Guild/guildpass-sdk/issues/390). `BatchItemResult` gains an optional `code` field, populated on every `{ status: 'error' }` entry returned by `batchEthCall`, `getMembershipTokenBalancesBatch` and `getGuildOwnersBatch`. Callers debugging a partial failure at scale can finally branch on *why* an individual item failed — a contract revert, an unusable node response, a consensus disagreement — instead of pattern-matching a human-readable string that was never a stable API. + - Codes are consistent across all three batch strategies (JSON-RPC batch, Multicall3 `aggregate3`, and the adaptive provider's sequential fallback): a contract-level revert is `HTTP_ERROR` wherever it occurs, matching the code the single-call `eth_call` path already throws for the identical JSON-RPC error envelope; a missing, empty, wrong-typed, oversized or short response is `INVALID_RESPONSE`; a per-item quorum failure is `CONSENSUS_MISMATCH`. Which strategy the adaptive provider happened to pick can no longer change an item's classification. + - Three sites previously discarded the `GuildPassError` they caught, flattening a precise code into a bare string: the adaptive provider's sequential fallback, and the balance and guild-owner decode steps (both a bare `catch {}`). All three now propagate the original `code`, falling back to `UNKNOWN_ERROR` and `INVALID_RESPONSE` respectively. A code raised by a custom `contractProvider` survives to the caller unchanged. + - **Every `error` string is byte-for-byte unchanged**, including `'execution reverted'`, `'Reverted: '`, `'Missing Multicall3 result'` and `'Failed to decode balance result'`; existing assertions and log scrapers keep working. `code` is added alongside them, never in place of them. + - Purely additive: `code` is optional on the existing flat `BatchItemResult` rather than turning it into a discriminated union, so code that reads `item.result` without narrowing still compiles. Success entries never carry a `code`. + - The field is optional only because `ContractProvider` is a public interface a third party can implement. Results from a user-supplied `contractProvider` are normalised as they enter `ContractClient`: a missing `code`, or one that is not a member of this build's `GuildPassErrorCode`, becomes `UNKNOWN_ERROR`. An error entry from any of the three batch methods therefore always carries a real enum member — `code: undefined` is only observable if you call a `ContractProvider` directly instead of going through the client. + - Pre-flight validation is unchanged: an invalid wallet address, an empty array, or a batch exceeding `maxBatchSize` still **throws** before any RPC call rather than becoming a per-item entry. `code` classifies failures that happen *during* execution. Documented with a branching example in [`docs/api-reference.md`](docs/api-reference.md). + - `GuildPassErrorCode` is a string enum, so compare with `GuildPassErrorCode.HTTP_ERROR` rather than the `'HTTP_ERROR'` literal. + - Per this project's pre-1.0 versioning policy this ships as a **minor** bump. +- **`GuildPassErrorCode.WS_CONNECTION_ERROR` now exists** — found while implementing [#390](https://github.com/Adamantine-Guild/guildpass-sdk/issues/390). `WebSocketContractProvider` referenced this member at six sites but it was never declared on the enum, so every WebSocket failure was constructed with `code: undefined`. + - The consequences were invisible in-process because `isGuildPassError` short-circuits on `instanceof`, but a code of `undefined` fails the structural branch of that guard — so a WebSocket error that crossed a realm boundary, arrived from a duplicated copy of the SDK, or was serialised via `toJSON()` was no longer recognisable as a GuildPass error at all, and carried nothing to branch on. + - Two of the provider's tests asserted `code: GuildPassErrorCode.WS_CONNECTION_ERROR`, which itself evaluated to `undefined`; they compared `undefined` to `undefined` and passed while proving nothing. They now assert a real value. + - Additive: no existing code is affected, and the enum's other 29 members are unchanged. - **`client.guilds.getGuildConfigBatch(params, options?)`** — resolves [#389](https://github.com/Adamantine-Guild/guildpass-sdk/issues/389). Fetches configuration for several guilds in one call, returning `BatchItemResult[]` in input order with per-guild failure isolation, matching the contract of `checkAccessBatch` and `getGuildOwnersBatch`. - **`BatchItemResult` is now generic**, `BatchItemResult`. The default preserves the existing meaning (raw hex from the contract batch methods) so every current call site and consumer type stays source-compatible; batch methods resolving richer values parameterise it instead. - Client-side fan-out over the existing `GET /guilds/:id/config` endpoint — no batch endpoint is assumed — through a bounded worker pool. `concurrency` defaults to `5` and is capped at `50`, matching `checkAccessBatch`. @@ -28,6 +41,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - `InMemoryCacheAdapterOptions` is exported for consumers who want to type their options. ### Fixed +- **`toErrorCode` no longer returns a code that is not a real enum member** — hardening found while implementing [#390](https://github.com/Adamantine-Guild/guildpass-sdk/issues/390). Because `isGuildPassError` short-circuits on `instanceof`, an error carrying a code that does not exist at runtime passed the guard and its `undefined` was returned verbatim, defeating the `fallback` argument and breaking the function's own return type. + - This is reachable from user code: a custom `contractProvider` compiled against a different SDK version can throw a `GuildPassError` whose code has since been renamed or removed. Such an item now falls back to `UNKNOWN_ERROR` (or the caller's chosen fallback) instead of surfacing a `BatchItemResult` with `code: undefined`, which is exactly the state [#390](https://github.com/Adamantine-Guild/guildpass-sdk/issues/390) exists to eliminate. + - New `tests/error-code-integrity.test.ts` locks the invariant repo-wide: it scans every `GuildPassErrorCode.X` reference in `src/` (ignoring comments) and fails if the member is not declared, asserts each member's value equals its own name, and checks that an error built from any member survives both a realm boundary and a JSON round-trip. It reproduces the `WS_CONNECTION_ERROR` bug when run against the previous code. - **`chains` entries are now resolved as overrides instead of replacements** — resolves [#393](https://github.com/Adamantine-Guild/guildpass-sdk/issues/393). `resolveChainConfig` returned the matching `chains[chainId]` entry verbatim, so a partial entry such as `chains: { 8453: { contractAddress: '0x…' } }` silently discarded the top-level `rpcUrl` and the call failed with a generic error that never mentioned the chain. Every field a `chains` entry does not declare is now inherited from the top level, matching what the README has always documented. - Fields are merged individually rather than by spreading, so a field an entry declares as `undefined` no longer clobbers a usable top-level value. - Whether an RPC endpoint is present is decided through `mergeRpcUrls`, so a config supplying only `rpcUrls` is no longer misreported as missing `rpcUrl`. diff --git a/api-report/guildpass-sdk.api.md b/api-report/guildpass-sdk.api.md index 6bdb4be..a489bba 100644 --- a/api-report/guildpass-sdk.api.md +++ b/api-report/guildpass-sdk.api.md @@ -214,6 +214,7 @@ export type BatchItemResult = { status: 'success' | 'error'; result?: T; error?: string; + code?: GuildPassErrorCode; }; // @public @@ -836,7 +837,8 @@ export enum GuildPassErrorCode { // (undocumented) UNKNOWN_ERROR = "UNKNOWN_ERROR", // (undocumented) - UNVERIFIABLE_RESPONSE = "UNVERIFIABLE_RESPONSE" + UNVERIFIABLE_RESPONSE = "UNVERIFIABLE_RESPONSE", + WS_CONNECTION_ERROR = "WS_CONNECTION_ERROR" } // @public @@ -946,9 +948,11 @@ export class HealthTracker { get multicallPreferenceThreshold(): number; recordFailure(url: string, now?: number): void; recordSuccess(url: string, latencyMs: number): void; + recordTimeout(url: string, now?: number): void; snapshot(url: string): Readonly | undefined; // (undocumented) snapshotAll(): Record>; + timeoutCount(url: string): number; } // @public (undocumented) @@ -1502,6 +1506,12 @@ export interface SiweVerifyResult { success: boolean; } +// @public +export interface SubscribableContractProvider extends ContractProvider { + destroy(): void; + subscribe(contractAddress: string, callback: TransferCallback): Promise<() => void>; +} + // @public (undocumented) export const SUPPORTED_NETWORKS: Record; @@ -1540,6 +1550,18 @@ export type TokenBalancesBatchParams = { chunkConcurrency?: number; }; +// @public +export type TransferCallback = (event: TransferEvent) => void; + +// @public +export type TransferEvent = { + from: string; + to: string; + value: bigint; + transactionHash: string; + blockNumber: number; +}; + // @public (undocumented) export interface TransportRequest { // (undocumented) @@ -1583,6 +1605,7 @@ export type UrlHealth = { latencyEmaMs: number; circuitOpen: boolean; openUntil: number; + timeoutCount?: number; }; // @public @@ -1662,9 +1685,19 @@ export function verifySiweSignatureWithReplayProtection(params: SiweVerifyAsyncP // @public export function verifyTypedDataSignature(domain: EIP712Domain, types: EIP712Types, primaryType: string, message: EIP712Message, signature: string, expectedSigner: string): EIP712VerifyResult; +// @public +export type WebSocketProviderConfig = { + wssUrl: string; + maxReconnects?: number; + baseDelayMs?: number; + maxDelayMs?: number; + subscribeTimeoutMs?: number; + requestTimeoutMs?: number; +}; + // Warnings were encountered during analysis: // -// dist/common-_RgT9NFP.d.ts:239:5 - (ae-forgotten-export) The symbol "ClientMetadata" needs to be exported by the entry point index.d.ts +// dist/errorCodes-DuQ7Uugs.d.ts:239:5 - (ae-forgotten-export) The symbol "ClientMetadata" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/docs/api-reference.md b/docs/api-reference.md index e1120f2..db1ba61 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -468,6 +468,85 @@ for (const [chainId, result] of Object.entries(balances)) { - **Partial failures**: a failed chain is reported per-chain; all other chains are unaffected and returned normally. - **Errors**: throws `INVALID_CONFIG` when no chains are configured, `INVALID_ADDRESS` for invalid wallet addresses. Per-chain RPC and contract errors are captured inside each chain's `ChainBalanceResult` rather than surfaced as thrown exceptions. +### `BatchItemResult` + +The per-item result type shared by `getMembershipTokenBalancesBatch`, +`getGuildOwnersBatch` and `batchEthCall`. + +```typescript +type BatchItemResult = { + status: 'success' | 'error'; + result?: string; // present when status is 'success' + error?: string; // present when status is 'error' + code?: GuildPassErrorCode; // present when status is 'error' +}; +``` + +Every error entry returned by `client.contracts.*` carries a `code` classifying +*why* that item failed, so callers can branch on failure type without matching +on the `error` string (whose exact wording is not a stable API). Success entries +never carry a `code`. + +| `code` | Meaning | Worth retrying elsewhere? | +| --- | --- | --- | +| `HTTP_ERROR` | The call reached the contract and was rejected — a revert, or a JSON-RPC error envelope from the node. | No — deterministic. | +| `INVALID_RESPONSE` | The node's response for this item was unusable: missing, empty, wrong-typed, over the response size cap, or undecodable. | Yes — another endpoint may succeed. | +| `CONSENSUS_MISMATCH` | `contractReadConsensus` is configured and the providers did not reach quorum for this item. | No — treat the value as unverified. | + +Any other `GuildPassErrorCode` may appear when propagated from a custom +`contractProvider`, falling back to `UNKNOWN_ERROR`. + +**Pre-flight validation still throws.** An invalid address, an empty input +array or a batch exceeding `maxBatchSize` rejects the whole call before any RPC +request is sent. `code` classifies failures that occur *during* execution. + +**`code` is optional in the type, but the batch methods always populate it.** +The field is optional because the `ContractProvider` interface is public and a +third-party implementation may omit it. Results from such a provider are +normalised as they enter `ContractClient`: a missing `code`, or one that is not +a member of `GuildPassErrorCode` in this build, becomes `UNKNOWN_ERROR`. So on +an error entry from `batchEthCall`, `getMembershipTokenBalancesBatch` or +`getGuildOwnersBatch`, `code` is always present and always a real enum member. + +The only way to observe `code: undefined` is to call a `ContractProvider` +yourself rather than going through the client — that result has not passed +through the normalisation step. + +```typescript +import { GuildPassErrorCode } from '@guildpass/sdk'; + +const results = await client.contracts.getMembershipTokenBalancesBatch({ walletAddresses }); + +const retryable: string[] = []; + +results.forEach((item, i) => { + if (item.status === 'success') { + console.log(walletAddresses[i], item.result); + return; + } + + switch (item.code) { + case GuildPassErrorCode.HTTP_ERROR: + // Reached the contract and was rejected. Retrying will not help. + console.warn(`${walletAddresses[i]} reverted: ${item.error}`); + break; + case GuildPassErrorCode.INVALID_RESPONSE: + // The node's answer was unusable — another endpoint may succeed. + retryable.push(walletAddresses[i]); + break; + case GuildPassErrorCode.CONSENSUS_MISMATCH: + // Providers disagreed. Never fall back to treating this as zero. + throw new Error(`Unverified balance for ${walletAddresses[i]}`); + default: + console.error(`${walletAddresses[i]}: ${item.error} (${item.code})`); + } +}); +``` + +> `GuildPassErrorCode` is a string enum, so compare against +> `GuildPassErrorCode.HTTP_ERROR` rather than the `'HTTP_ERROR'` literal — +> TypeScript rejects the bare string as having no overlap. + ### `getMembershipTokenBalancesBatch(params: TokenBalancesBatchParams)` Fetches membership token balances for multiple wallet addresses in a single @@ -488,11 +567,12 @@ await client.contracts.getMembershipTokenBalancesBatch({ - **Returns**: `Promise` — ordered results, one per input address. Each result has `{ status: 'success', result: '' }` or - `{ status: 'error', error: '' }`. + `{ status: 'error', error: '', code: GuildPassErrorCode }`. + See [`BatchItemResult`](#batchitemresult) for the codes and a branching example. - **Requires**: same config as `getMembershipTokenBalance` - **Contract call**: single JSON-RPC batch of `eth_call` to `balanceOf(address)` - **Partial failures**: a failed address is reported individually; other addresses are unaffected -- **Errors**: throws `INVALID_INPUT` for empty arrays, `INVALID_ADDRESS` if any address is invalid (pre-flight), `INVALID_CONFIG` for missing RPC/contract config, `INVALID_RESPONSE` for non-array or malformed batch responses +- **Errors**: throws `INVALID_INPUT` for empty arrays, `INVALID_ADDRESS` if any address is invalid (pre-flight), `INVALID_CONFIG` for missing RPC/contract config, `INVALID_RESPONSE` for non-array or malformed batch responses. Pre-flight failures throw; failures during execution are reported per item with a `code`. ### `getGuildOwnersBatch(params: GuildOwnersBatchParams)` @@ -511,12 +591,13 @@ await client.contracts.getGuildOwnersBatch({ - **Returns**: `Promise` — ordered results, one per input guild ID. Each result has `{ status: 'success', result: '' }` or - `{ status: 'error', error: '' }`. + `{ status: 'error', error: '', code: GuildPassErrorCode }`. + See [`BatchItemResult`](#batchitemresult) for the codes and a branching example. - **Requires**: same config as `getGuildOwner` - **Contract call**: single JSON-RPC batch of `eth_call` to `getGuildOwner(bytes32)` - **Guild ID encoding**: each guild ID in the array is encoded using the same strict three-mode rules as `getGuildOwner` (see above) - **Partial failures**: a failed guild is reported individually; other guilds are unaffected -- **Errors**: throws `INVALID_INPUT` for empty arrays, `INVALID_INPUT` if any guild ID is invalid (pre-flight), `INVALID_CONFIG` for missing RPC/contract config, `INVALID_RESPONSE` for non-array or malformed batch responses +- **Errors**: throws `INVALID_INPUT` for empty arrays, `INVALID_INPUT` if any guild ID is invalid (pre-flight), `INVALID_CONFIG` for missing RPC/contract config, `INVALID_RESPONSE` for non-array or malformed batch responses. Pre-flight failures throw; failures during execution are reported per item with a `code`. ### RPC Failover (`rpcUrls`) @@ -590,6 +671,16 @@ interface ContractProvider { } ``` +Implementations **should** set `code` on every error entry they return (see +[`BatchItemResult`](#batchitemresult)), but are not required to: `ContractClient` +normalises anything missing or unrecognised to `UNKNOWN_ERROR` as it comes back, +so an omission degrades the classification rather than breaking callers. Setting +an accurate code is still strongly preferred — `UNKNOWN_ERROR` tells a consumer +nothing about whether retrying elsewhere is worthwhile. + +The bundled viem and ethers adapters always set it, propagating whatever code +their `ethCall` raised. + Reuse an existing viem or ethers provider via the tree-shakeable adapter subpaths (viem/ethers are optional peer dependencies — never bundled unless you import an adapter): @@ -632,8 +723,9 @@ legitimate return: a 1,000-call Multicall3 batch of 32-byte results is roughly 1 - Single `eth_call` (including the Multicall3 `aggregate3` envelope): exceeding the cap throws `INVALID_RESPONSE`, which the failover logic treats as an endpoint failure. -- JSON-RPC batch: an oversized *item* is reported as that item's error entry and does not - fail its siblings, matching the existing per-item contract. +- JSON-RPC batch: an oversized *item* is reported as that item's error entry, carrying + `code: INVALID_RESPONSE`, and does not fail its siblings, matching the existing + per-item contract. Structurally malformed responses are rejected the same way — a non-hex body, a truncated ABI word, an offset that is not 32-byte aligned or points outside the payload, a @@ -657,10 +749,12 @@ const results = await client.contracts.batchEthCall( ); ``` -- **Returns**: `Promise` — ordered results, one per input call +- **Returns**: `Promise` — ordered results, one per input call. + A failed item is `{ status: 'error', error: '', code: GuildPassErrorCode }`; + see [`BatchItemResult`](#batchitemresult) for the codes and a branching example. - **Partial failures**: each call is individually resolved; errors do not affect sibling calls - **Input validation**: each `to` address is validated as an Ethereum address before the RPC request is built -- **Errors**: throws `INVALID_INPUT` for empty/ invalid call descriptors, `INVALID_CONFIG` for missing `rpcUrl`, `INVALID_ADDRESS` for malformed `to` addresses, `HTTP_ERROR` for HTTP or RPC-level failures, `INVALID_RESPONSE` for non-array or structurally malformed batch responses +- **Errors**: throws `INVALID_INPUT` for empty/ invalid call descriptors, `INVALID_CONFIG` for missing `rpcUrl`, `INVALID_ADDRESS` for malformed `to` addresses, `HTTP_ERROR` for HTTP or RPC-level failures, `INVALID_RESPONSE` for non-array or structurally malformed batch responses. Pre-flight failures throw; failures during execution are reported per item with a `code`. - **Provider compatibility**: works with any JSON-RPC provider that supports [batch requests](https://www.jsonrpc.org/specification#batch) - **Custom providers**: when a `contractProvider` is configured it takes precedence and `rpcUrl` may be omitted; `INVALID_CONFIG` is only thrown when neither is available diff --git a/src/adapters/ethers.ts b/src/adapters/ethers.ts index b3e6fa1..9696027 100644 --- a/src/adapters/ethers.ts +++ b/src/adapters/ethers.ts @@ -3,6 +3,7 @@ import { GuildPassError } from '../errors/GuildPassError'; import { GuildPassErrorCode } from '../errors/errorCodes'; import { RequestOptions } from '../types/common'; import { BatchItemResult } from '../contracts/contract.types'; +import { batchItemError } from '../contracts/batchErrors'; import { ContractProvider, EthCallRequest } from '../contracts/providers/provider.types'; /** @@ -58,11 +59,21 @@ export function ethersContractProvider(provider: EthersProviderLike): ContractPr try { const result = await ethCall(request); if (typeof result !== 'string') { - return { status: 'error', error: `Unexpected result type for batch item ${i}` }; + return batchItemError( + `Unexpected result type for batch item ${i}`, + GuildPassErrorCode.INVALID_RESPONSE, + ); } return { status: 'success', result }; } catch (err: any) { - return { status: 'error', error: err?.message ?? `RPC error for batch item ${i}` }; + // `ethCall` above wraps every ethers failure as a GuildPassError, so + // propagate whatever code it chose rather than hardcoding one here. + // `instanceof` is sufficient and exact: the error was constructed by + // this same module, so it can never have crossed a realm boundary. + return batchItemError( + err?.message ?? `RPC error for batch item ${i}`, + err instanceof GuildPassError ? err.code : GuildPassErrorCode.HTTP_ERROR, + ); } }), ); diff --git a/src/adapters/viem.ts b/src/adapters/viem.ts index 3a75734..d7520bc 100644 --- a/src/adapters/viem.ts +++ b/src/adapters/viem.ts @@ -3,6 +3,7 @@ import { GuildPassError } from '../errors/GuildPassError'; import { GuildPassErrorCode } from '../errors/errorCodes'; import { RequestOptions } from '../types/common'; import { BatchItemResult } from '../contracts/contract.types'; +import { batchItemError } from '../contracts/batchErrors'; import { ContractProvider, EthCallRequest } from '../contracts/providers/provider.types'; /** @@ -60,11 +61,21 @@ export function viemContractProvider(client: ViemPublicClientLike): ContractProv try { const result = await ethCall(request); if (typeof result !== 'string') { - return { status: 'error', error: `Unexpected result type for batch item ${i}` }; + return batchItemError( + `Unexpected result type for batch item ${i}`, + GuildPassErrorCode.INVALID_RESPONSE, + ); } return { status: 'success', result }; } catch (err: any) { - return { status: 'error', error: err?.message ?? `RPC error for batch item ${i}` }; + // `ethCall` above wraps every viem failure as a GuildPassError, so + // propagate whatever code it chose rather than hardcoding one here. + // `instanceof` is sufficient and exact: the error was constructed by + // this same module, so it can never have crossed a realm boundary. + return batchItemError( + err?.message ?? `RPC error for batch item ${i}`, + err instanceof GuildPassError ? err.code : GuildPassErrorCode.HTTP_ERROR, + ); } }), ); diff --git a/src/contracts/batchErrors.ts b/src/contracts/batchErrors.ts new file mode 100644 index 0000000..bc0fe67 --- /dev/null +++ b/src/contracts/batchErrors.ts @@ -0,0 +1,50 @@ +// GuildPass SDK: Pull in package or module bindings. +import { GuildPassErrorCode } from '../errors/errorCodes'; +import { isValidErrorCode } from '../errors/toErrorCode'; +import type { BatchItemResult } from './contract.types'; + +/** + * Builds an error entry for a batch result array. + * + * Every per-item failure the SDK produces goes through here so that `status`, + * `error` and `code` are always populated together — an error entry without a + * code is a bug, not a supported shape. + * + * @param message - Human-readable description of the failure. + * @param code - Classification for {@link BatchItemResult.code}. + * @returns An error entry suitable for a `BatchItemResult[]`. + */ +export function batchItemError(message: string, code: GuildPassErrorCode): BatchItemResult { + return { status: 'error', error: message, code }; +} + +/** + * Guarantees every error entry in a batch result carries a usable + * {@link GuildPassErrorCode}, defaulting to `UNKNOWN_ERROR`. + * + * Applied to results coming back from a user-supplied `contractProvider` — the + * one place a batch entry can enter `ContractClient` without passing through + * {@link batchItemError}. `ContractProvider` is a public interface, so a + * third-party implementation may legitimately omit `code` or, if compiled + * against a different SDK version, supply one that is no longer a member. Both + * would otherwise surface as `code: undefined` from a public batch method and + * silently drop every caller into their `default` branch. + * + * Returns the input array unchanged when nothing needs patching, so the common + * case allocates nothing. + * + * @param results - Per-item results as returned by a provider. + * @returns The same array, or a patched copy. + */ +export function ensureItemCodes(results: BatchItemResult[]): BatchItemResult[] { + const needsPatch = results.some( + (item) => item.status === 'error' && !isValidErrorCode(item.code), + ); + if (!needsPatch) return results; + + return results.map((item) => + item.status === 'error' && !isValidErrorCode(item.code) + ? { ...item, code: GuildPassErrorCode.UNKNOWN_ERROR } + : item, + ); +} diff --git a/src/contracts/contract.types.ts b/src/contracts/contract.types.ts index c644563..cc59371 100644 --- a/src/contracts/contract.types.ts +++ b/src/contracts/contract.types.ts @@ -1,5 +1,7 @@ // GuildPass SDK: Import external module dependencies. import { AccessRequirement } from '../types/common'; +// Type-only: keeps this module fully erasable at build time. +import type { GuildPassErrorCode } from '../errors/errorCodes'; /** Per-chain RPC and contract address configuration. */ export type ChainConfig = { @@ -78,7 +80,8 @@ export type BatchEthCallItem = { /** * Result of a single item in a batch response. * On success, `status` is `'success'` and `result` contains the item's value. - * On failure, `status` is `'error'` and `error` contains a descriptive message. + * On failure, `status` is `'error'`, `error` contains a descriptive message and + * `code` classifies the failure. * * `T` defaults to `string`, which is the raw hex output returned by the * contract batch methods, so `BatchItemResult` on its own keeps meaning exactly @@ -86,9 +89,39 @@ export type BatchEthCallItem = { * instead — for example `BatchItemResult`. */ export type BatchItemResult = { + /** `'success'` when the call returned usable data, `'error'` when this item failed. */ status: 'success' | 'error'; + /** The call's return value. Present when `status` is `'success'`. */ result?: T; + /** + * Human-readable description of why this item failed. Present when `status` + * is `'error'`. The exact wording is not a stable API — branch on + * {@link BatchItemResult.code} rather than matching this string. + */ error?: string; + /** + * Machine-readable classification of the failure. Populated on every + * `'error'` entry produced by the SDK, and never set on a `'success'` entry. + * + * - `HTTP_ERROR` — the call reached the contract and was rejected: a revert, + * or a JSON-RPC error envelope from the node. Deterministic; retrying the + * same call against another endpoint will not change the outcome. + * - `INVALID_RESPONSE` — the node's response for this item was unusable: + * missing, empty, wrong-typed, over the response size cap, or undecodable. + * Another endpoint may well succeed. + * - `CONSENSUS_MISMATCH` — `contractReadConsensus` is configured and the + * providers did not reach quorum for this item. + * - Any other {@link GuildPassErrorCode} propagated from a custom + * `contractProvider`, falling back to `UNKNOWN_ERROR`. + * + * Note that pre-flight validation failures (an invalid address, an empty + * input array, a batch exceeding `maxBatchSize`) are **thrown** rather than + * reported here; this field classifies failures that occur during execution. + * + * Optional for backward compatibility: entries produced by a third-party + * {@link ContractProvider} implementation may omit it. + */ + code?: GuildPassErrorCode; }; /** diff --git a/src/contracts/contractClient.ts b/src/contracts/contractClient.ts index 92c69cc..6574d3d 100644 --- a/src/contracts/contractClient.ts +++ b/src/contracts/contractClient.ts @@ -54,6 +54,8 @@ import { encodeAbiParams, validateAccessRequirement, } from './contractHelpers'; +import { batchItemError, ensureItemCodes } from './batchErrors'; +import { toErrorCode } from '../errors/toErrorCode'; import { GuildPassClientConfig, resolveChainConfig, mergeRpcUrls } from '../config/sdkConfig'; import { HttpClient } from '../http/httpClient'; import { RequestOptions } from '../types/common'; @@ -966,10 +968,12 @@ export class ContractClient { } else { let totalVotes = 0; for (const g of groupByValue.values()) totalVotes += g.count; - results.push({ - status: 'error', - error: `Consensus mismatch at batch index ${i}: largest agreeing group returned ${winningCount} matching value(s) (quorum: ${minProviders}, total successful votes: ${totalVotes}).`, - }); + results.push( + batchItemError( + `Consensus mismatch at batch index ${i}: largest agreeing group returned ${winningCount} matching value(s) (quorum: ${minProviders}, total successful votes: ${totalVotes}).`, + GuildPassErrorCode.CONSENSUS_MISMATCH, + ), + ); } } @@ -997,7 +1001,11 @@ export class ContractClient { const requests: EthCallRequest[] = calls.map((c) => ({ to: c.to, data: c.data })); if (this.config.contractProvider) { - return this.config.contractProvider.batchEthCall(requests, options); + // The only untrusted source of batch entries: a third-party provider may + // omit `code`, or carry one from a different SDK version. Normalise here, + // the single point every provider result enters ContractClient, so the + // public batch methods can promise a usable code unconditionally. + return ensureItemCodes(await this.config.contractProvider.batchEthCall(requests, options)); } if (this.config.contractReadConsensus) { @@ -1361,11 +1369,13 @@ export class ContractClient { status: 'success' as const, result: decodeUint256Result(item.result), }; - } catch { - return { - status: 'error' as const, - error: 'Failed to decode balance result', - }; + } catch (err) { + // decodeUint256Result throws INVALID_RESPONSE; keep whatever code it + // carried rather than swallowing the error entirely. + return batchItemError( + 'Failed to decode balance result', + toErrorCode(err, GuildPassErrorCode.INVALID_RESPONSE), + ); } } return item; @@ -1446,11 +1456,13 @@ export class ContractClient { status: 'success' as const, result: decodeAddressResult(item.result), }; - } catch { - return { - status: 'error' as const, - error: 'Failed to decode guild owner result', - }; + } catch (err) { + // decodeAddressResult throws INVALID_RESPONSE; keep whatever code it + // carried rather than swallowing the error entirely. + return batchItemError( + 'Failed to decode guild owner result', + toErrorCode(err, GuildPassErrorCode.INVALID_RESPONSE), + ); } } return item; diff --git a/src/contracts/providers/adaptiveContractProvider.ts b/src/contracts/providers/adaptiveContractProvider.ts index 3328458..c939d73 100644 --- a/src/contracts/providers/adaptiveContractProvider.ts +++ b/src/contracts/providers/adaptiveContractProvider.ts @@ -4,6 +4,8 @@ import { GuildPassErrorCode } from '../../errors/errorCodes'; import { HttpClient } from '../../http/httpClient'; import { RequestOptions } from '../../types/common'; import { BatchItemResult } from '../contract.types'; +import { batchItemError } from '../batchErrors'; +import { toErrorCode } from '../../errors/toErrorCode'; import { ContractProvider, EthCallRequest } from './provider.types'; import { JsonRpcContractProvider } from './jsonRpcProvider'; import { HealthTracker } from './healthTracker'; @@ -399,10 +401,14 @@ export class AdaptiveContractProvider implements ContractProvider { // A transient error here should bubble so the caller can fail the URL // over; a contract-level error is a legitimate per-item failure. if (isTransient(err)) throw err; - results.push({ - status: 'error', - error: err instanceof Error ? err.message : 'eth_call failed', - }); + // The caught GuildPassError carries the only precise classification + // available for this item; flattening it to a string would discard it. + results.push( + batchItemError( + err instanceof Error ? err.message : 'eth_call failed', + toErrorCode(err, GuildPassErrorCode.UNKNOWN_ERROR), + ), + ); } } return results; diff --git a/src/contracts/providers/jsonRpcProvider.ts b/src/contracts/providers/jsonRpcProvider.ts index 0a51b00..12b5d94 100644 --- a/src/contracts/providers/jsonRpcProvider.ts +++ b/src/contracts/providers/jsonRpcProvider.ts @@ -4,6 +4,7 @@ import { GuildPassErrorCode } from '../../errors/errorCodes'; import { HttpClient } from '../../http/httpClient'; import { BlockTag, RequestOptions } from '../../types/common'; import { BatchItemResult } from '../contract.types'; +import { batchItemError } from '../batchErrors'; import { ContractProvider, EthCallRequest } from './provider.types'; import { HttpHooks, RpcFailoverHookPayload } from '../../http/http.types'; import { MAX_RPC_RESPONSE_BYTES, assertResultWithinCap, exceedsResponseCap } from './hexGuards'; @@ -263,33 +264,45 @@ export class JsonRpcContractProvider implements ContractProvider { const payload = responseMap.get(expectedId); if (!payload) { - results.push({ - status: 'error', - error: `No response for batch item ${i} (id: ${expectedId})`, - }); + // The node returned a batch array that omits an id we asked for: a + // malformed envelope, same class of fault as a non-array response. + results.push( + batchItemError( + `No response for batch item ${i} (id: ${expectedId})`, + GuildPassErrorCode.INVALID_RESPONSE, + ), + ); } else if (payload.error) { - results.push({ - status: 'error', - error: payload.error.message ?? `RPC error (code: ${payload.error.code})`, - }); + // The single-call path wraps this exact same `payload.error` as + // HTTP_ERROR, and `isTransientError` keys off that; the batch path must + // not disagree with it about an identical wire event. + results.push( + batchItemError( + payload.error.message ?? `RPC error (code: ${payload.error.code})`, + GuildPassErrorCode.HTTP_ERROR, + ), + ); } else if (payload.result === undefined || payload.result === null) { - results.push({ - status: 'error', - error: `Empty result for batch item ${i}`, - }); + results.push( + batchItemError(`Empty result for batch item ${i}`, GuildPassErrorCode.INVALID_RESPONSE), + ); } else if (typeof payload.result !== 'string') { - results.push({ - status: 'error', - error: `Unexpected result type for batch item ${i}`, - }); + results.push( + batchItemError( + `Unexpected result type for batch item ${i}`, + GuildPassErrorCode.INVALID_RESPONSE, + ), + ); } else if (exceedsResponseCap(payload.result)) { // Unlike the single-call path, one oversized item must not sink the // whole batch: the per-item contract is an error entry, exactly as for // an RPC error or a wrong-typed result. - results.push({ - status: 'error', - error: `Result for batch item ${i} exceeds the ${MAX_RPC_RESPONSE_BYTES}-byte response cap`, - }); + results.push( + batchItemError( + `Result for batch item ${i} exceeds the ${MAX_RPC_RESPONSE_BYTES}-byte response cap`, + GuildPassErrorCode.INVALID_RESPONSE, + ), + ); } else { results.push({ status: 'success', diff --git a/src/contracts/providers/multicall3Provider.ts b/src/contracts/providers/multicall3Provider.ts index b73e73b..225ff17 100644 --- a/src/contracts/providers/multicall3Provider.ts +++ b/src/contracts/providers/multicall3Provider.ts @@ -4,6 +4,7 @@ import { GuildPassErrorCode } from '../../errors/errorCodes'; import { HttpClient } from '../../http/httpClient'; import { RequestOptions } from '../../types/common'; import { BatchItemResult } from '../contract.types'; +import { batchItemError } from '../batchErrors'; import { ContractProvider, EthCallRequest } from './provider.types'; import { JsonRpcContractProvider } from './jsonRpcProvider'; import { HttpHooks } from '../../http/http.types'; @@ -230,13 +231,17 @@ export function decodeAggregate3(raw: string, expected: number): BatchItemResult results.push( successWord === 1 ? { status: 'success', result: '0x' + dataHex } - : { status: 'error', error: decodeRevertReason(dataHex) }, + : // `success == false` on a structurally valid response means the call + // reached the contract and reverted — the same event the JSON-RPC + // path reports as HTTP_ERROR. Which strategy the adaptive provider + // picked must not change an item's classification. + batchItemError(decodeRevertReason(dataHex), GuildPassErrorCode.HTTP_ERROR), ); } // Defensive: a structurally valid but short array still pads, as before. while (results.length < expected) { - results.push({ status: 'error', error: 'Missing Multicall3 result' }); + results.push(batchItemError('Missing Multicall3 result', GuildPassErrorCode.INVALID_RESPONSE)); } // No trailing `slice` needed: `length > expected` now throws above. diff --git a/src/errors/errorCodes.ts b/src/errors/errorCodes.ts index b70aab7..6102992 100644 --- a/src/errors/errorCodes.ts +++ b/src/errors/errorCodes.ts @@ -17,6 +17,14 @@ export enum GuildPassErrorCode { UNKNOWN_ERROR = 'UNKNOWN_ERROR', ABORTED = 'ABORTED', CACHE_ERROR = 'CACHE_ERROR', + /** + * A WebSocket transport failure: the socket could not be opened, was closed + * mid-request, exhausted its reconnect budget, or the provider was destroyed + * with requests still in flight. + * + * Only surfaced by `WebSocketContractProvider`. + */ + WS_CONNECTION_ERROR = 'WS_CONNECTION_ERROR', // SIWE (Sign-In With Ethereum) error codes SIWE_INVALID_SIGNATURE = 'SIWE_INVALID_SIGNATURE', SIWE_EXPIRED = 'SIWE_EXPIRED', @@ -38,7 +46,10 @@ export enum GuildPassErrorCode { * results (raw values grouped by equality, plus per-provider failure * messages) so callers can identify the disagreeing endpoint(s). * - * Only thrown when the opt-in `contractReadConsensus` config is set. + * Only surfaced when the opt-in `contractReadConsensus` config is set. The + * batch read paths do not throw it: an item that fails to reach quorum + * becomes an error entry carrying this code in its `BatchItemResult.code`, + * leaving its siblings unaffected. */ CONSENSUS_MISMATCH = 'CONSENSUS_MISMATCH', // GuildPass SDK: End of logic containment structure block. diff --git a/src/errors/toErrorCode.ts b/src/errors/toErrorCode.ts new file mode 100644 index 0000000..a571d4f --- /dev/null +++ b/src/errors/toErrorCode.ts @@ -0,0 +1,52 @@ +// GuildPass SDK: Pull in package or module bindings. +import { GuildPassErrorCode } from './errorCodes'; +import { isGuildPassError } from './guards'; + +const VALID_ERROR_CODES = new Set(Object.values(GuildPassErrorCode)); + +/** + * Narrows an arbitrary value to a real {@link GuildPassErrorCode} member. + * + * A `code` that merely looks like one — `undefined` from a member that was + * referenced but never declared, or a string from a provider compiled against + * a different SDK version — must not reach a caller who is branching on the + * enum. Internal; not re-exported from any barrel. + */ +export function isValidErrorCode(code: unknown): code is GuildPassErrorCode { + return typeof code === 'string' && VALID_ERROR_CODES.has(code); +} + +/** + * Extracts the {@link GuildPassErrorCode} carried by an unknown thrown value. + * + * Used where an error must be flattened into a data structure rather than + * rethrown — per-item batch failures, where one item's error becomes an entry + * in a result array — so the classification survives even though the `Error` + * instance itself does not. + * + * Uses {@link isGuildPassError} rather than `instanceof` so a code still + * survives when the error crosses a realm boundary or originates from a + * duplicated copy of the SDK. + * + * No heuristics are applied to non-GuildPass errors: guessing a code from an + * error's shape would silently reclassify failures that callers branch on. + * An unrecognised value yields `fallback`. + * + * The extracted code is validated against the enum before being returned. + * {@link isGuildPassError} short-circuits on `instanceof`, so an error built + * with a code that does not exist at runtime — `GuildPassErrorCode.TYPO`, or a + * member removed since a custom `contractProvider` was compiled — passes the + * guard while carrying `undefined`. Returning that would break this function's + * own contract and hand callers an error entry with no usable classification. + * + * @param err - The caught value, of unknown type. + * @param fallback - Code to use when `err` carries no valid code. Defaults to `UNKNOWN_ERROR`. + * @returns The error's own code when it is a real enum member, else `fallback`. + */ +export function toErrorCode( + err: unknown, + fallback: GuildPassErrorCode = GuildPassErrorCode.UNKNOWN_ERROR, +): GuildPassErrorCode { + if (!isGuildPassError(err)) return fallback; + return isValidErrorCode(err.code) ? err.code : fallback; +} diff --git a/tests/batch-item-error-codes.test.ts b/tests/batch-item-error-codes.test.ts new file mode 100644 index 0000000..46fe6e4 --- /dev/null +++ b/tests/batch-item-error-codes.test.ts @@ -0,0 +1,453 @@ +/** + * Tests for structured per-item failure codes on `BatchItemResult` (#390). + * + * Acceptance criteria coverage: + * 1. Every error entry returned by `batchEthCall`, + * `getMembershipTokenBalancesBatch` and `getGuildOwnersBatch` carries a + * `code: GuildPassErrorCode` alongside the existing `error` string. + * 2. The `error` strings are unchanged in format — asserted with exact + * equality throughout, so a future reword fails loudly. + * 3. Codes are correct for HTTP failure, malformed RPC response and invalid + * address scenarios *within* a batch. + * + * The invalid-address criterion needs care: all three batch methods validate + * addresses **pre-flight and throw**, so a caller-supplied bad address never + * reaches a `BatchItemResult`. Both halves of that contract are pinned below — + * the throw is locked as a regression test, and a genuine per-item + * `INVALID_ADDRESS` (a node returning an owner word that fails EIP-55) proves + * the code survives the decode step that previously swallowed it. + */ +// GuildPass SDK: Pull in package or module bindings. +import { beforeEach, describe, expect, it, vi } from 'vitest'; +// GuildPass SDK: Import external module dependencies. +import { GuildPassClient } from '../src/client/GuildPassClient'; +import { GuildPassErrorCode } from '../src/errors/errorCodes'; +import { GuildPassError } from '../src/errors/GuildPassError'; +import { toErrorCode } from '../src/errors/toErrorCode'; +import { batchItemError } from '../src/contracts/batchErrors'; +import { viemContractProvider } from '../src/adapters/viem'; +import { ethersContractProvider } from '../src/adapters/ethers'; + +const BASE_URL = 'https://api.test.com'; +const RPC_URL = 'https://rpc.test.com'; +const CONTRACT = '0x0000000000000000000000000000000000000000'; +const WALLET_A = '0x1111111111111111111111111111111111111111'; +const WALLET_B = '0x2222222222222222222222222222222222222222'; + +/** A valid 32-byte uint256 word decoding to 5. */ +const FIVE = `0x${'0'.repeat(63)}5`; +/** A valid 32-byte word whose low 20 bytes are a legitimate address. */ +const OWNER_WORD = `0x${'0'.repeat(24)}${'9'.repeat(40)}`; + +const mockFetch = (): ReturnType => fetch as unknown as ReturnType; + +/** Wraps a JSON-RPC batch payload in the response shape `HttpClient` expects. */ +const rpcBatch = (items: unknown[]) => ({ + ok: true, + status: 200, + headers: new Headers({ 'Content-Type': 'application/json' }), + json: () => Promise.resolve(items), +}); + +const client = new GuildPassClient({ + apiUrl: BASE_URL, + rpcUrl: RPC_URL, + contractAddress: CONTRACT, +}); + +/** Two arbitrary well-formed calls, enough to prove sibling isolation. */ +const twoCalls = [ + { to: CONTRACT, data: '0xdeadbeef' }, + { to: CONTRACT, data: '0xcafebabe' }, +]; + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); +}); + +// --------------------------------------------------------------------------- +// HTTP failure within a batch +// --------------------------------------------------------------------------- + +describe('BatchItemResult.code — HTTP failure within a batch', () => { + it('classifies a JSON-RPC error envelope as HTTP_ERROR without affecting siblings', async () => { + mockFetch().mockResolvedValue( + rpcBatch([ + { jsonrpc: '2.0', id: 1, error: { code: -32000, message: 'execution reverted' } }, + { jsonrpc: '2.0', id: 2, result: FIVE }, + ]), + ); + + const results = await client.contracts.batchEthCall(twoCalls, RPC_URL); + + expect(results[0]).toEqual({ + status: 'error', + error: 'execution reverted', + code: GuildPassErrorCode.HTTP_ERROR, + }); + // A failed item must not disturb its sibling, nor gain a spurious code. + expect(results[1]).toEqual({ status: 'success', result: FIVE }); + }); + + it('falls back to the numeric RPC code in the message when no message is present', async () => { + mockFetch().mockResolvedValue(rpcBatch([{ jsonrpc: '2.0', id: 1, error: { code: -32000 } }])); + + const results = await client.contracts.batchEthCall([twoCalls[0]], RPC_URL); + + expect(results[0]).toEqual({ + status: 'error', + error: 'RPC error (code: -32000)', + code: GuildPassErrorCode.HTTP_ERROR, + }); + }); + + it.each([ + [ + 'viem', + () => + viemContractProvider({ + call: async () => { + throw new Error('boom'); + }, + }), + ], + [ + 'ethers', + () => + ethersContractProvider({ + call: async () => { + throw new Error('boom'); + }, + }), + ], + ])('%s adapter classifies a provider throw as HTTP_ERROR', async (_name, make) => { + const results = await make().batchEthCall([twoCalls[0]]); + + expect(results[0]).toEqual({ + status: 'error', + error: 'boom', + code: GuildPassErrorCode.HTTP_ERROR, + }); + }); +}); + +// --------------------------------------------------------------------------- +// Malformed RPC response within a batch +// --------------------------------------------------------------------------- + +describe('BatchItemResult.code — malformed RPC response', () => { + it('classifies a missing per-item response as INVALID_RESPONSE', async () => { + // Only id 2 comes back; id 1 was silently dropped by the node. + mockFetch().mockResolvedValue(rpcBatch([{ jsonrpc: '2.0', id: 2, result: FIVE }])); + + const results = await client.contracts.batchEthCall(twoCalls, RPC_URL); + + expect(results[0]).toEqual({ + status: 'error', + error: 'No response for batch item 0 (id: 1)', + code: GuildPassErrorCode.INVALID_RESPONSE, + }); + expect(results[1]).toEqual({ status: 'success', result: FIVE }); + }); + + it('classifies a null result as INVALID_RESPONSE', async () => { + mockFetch().mockResolvedValue(rpcBatch([{ jsonrpc: '2.0', id: 1, result: null }])); + + const results = await client.contracts.batchEthCall([twoCalls[0]], RPC_URL); + + expect(results[0]).toEqual({ + status: 'error', + error: 'Empty result for batch item 0', + code: GuildPassErrorCode.INVALID_RESPONSE, + }); + }); + + it('classifies a non-string result as INVALID_RESPONSE', async () => { + mockFetch().mockResolvedValue(rpcBatch([{ jsonrpc: '2.0', id: 1, result: 42 }])); + + const results = await client.contracts.batchEthCall([twoCalls[0]], RPC_URL); + + expect(results[0]).toEqual({ + status: 'error', + error: 'Unexpected result type for batch item 0', + code: GuildPassErrorCode.INVALID_RESPONSE, + }); + }); + + it('classifies an undecodable balance word as INVALID_RESPONSE', async () => { + // Structurally fine at the JSON-RPC layer, but not a 32-byte word: the + // failure surfaces in ContractClient's decode pass, which used to swallow + // the underlying GuildPassError entirely. + mockFetch().mockResolvedValue( + rpcBatch([ + { jsonrpc: '2.0', id: 1, result: '0x1234' }, + { jsonrpc: '2.0', id: 2, result: FIVE }, + ]), + ); + + const results = await client.contracts.getMembershipTokenBalancesBatch({ + walletAddresses: [WALLET_A, WALLET_B], + }); + + expect(results[0]).toEqual({ + status: 'error', + error: 'Failed to decode balance result', + code: GuildPassErrorCode.INVALID_RESPONSE, + }); + expect(results[1]).toEqual({ status: 'success', result: '5' }); + }); + + it('classifies an undecodable guild owner word as INVALID_RESPONSE', async () => { + mockFetch().mockResolvedValue( + rpcBatch([ + { jsonrpc: '2.0', id: 1, result: '0x1234' }, + { jsonrpc: '2.0', id: 2, result: OWNER_WORD }, + ]), + ); + + const results = await client.contracts.getGuildOwnersBatch({ + guildIds: ['guild_1', 'guild_2'], + }); + + expect(results[0]).toEqual({ + status: 'error', + error: 'Failed to decode guild owner result', + code: GuildPassErrorCode.INVALID_RESPONSE, + }); + expect(results[1].status).toBe('success'); + }); +}); + +// --------------------------------------------------------------------------- +// Invalid address +// --------------------------------------------------------------------------- + +describe('BatchItemResult.code — invalid address', () => { + it('still throws INVALID_ADDRESS pre-flight rather than reporting it per item', async () => { + // Regression lock. Pre-flight validation is deliberately fail-fast: a + // caller-supplied bad address never becomes a per-item entry, so this + // contract cannot drift silently now that items carry codes. + await expect( + client.contracts.getMembershipTokenBalancesBatch({ + walletAddresses: [WALLET_A, '0xnot-an-address'], + }), + ).rejects.toMatchObject({ code: GuildPassErrorCode.INVALID_ADDRESS }); + + expect(mockFetch()).not.toHaveBeenCalled(); + }); + + it('surfaces a per-item INVALID_ADDRESS when a node returns a bad owner address', async () => { + // A 32-byte word that decodes to a mixed-case address failing EIP-55. + // `decodeAddressResult` -> `validateAddress` throws INVALID_ADDRESS, which + // the decode step previously discarded via a bare `catch {}`. Proves an + // *arbitrary* code survives, not just the INVALID_RESPONSE fallback. + const badChecksumWord = `0x${'0'.repeat(24)}AbCdEf0123456789AbCdEf0123456789AbCdEf01`; + + mockFetch().mockResolvedValue( + rpcBatch([ + { jsonrpc: '2.0', id: 1, result: badChecksumWord }, + { jsonrpc: '2.0', id: 2, result: OWNER_WORD }, + ]), + ); + + const results = await client.contracts.getGuildOwnersBatch({ + guildIds: ['guild_1', 'guild_2'], + }); + + expect(results[0]).toEqual({ + status: 'error', + error: 'Failed to decode guild owner result', + code: GuildPassErrorCode.INVALID_ADDRESS, + }); + expect(results[1].status).toBe('success'); + }); +}); + +// --------------------------------------------------------------------------- +// Backward compatibility +// --------------------------------------------------------------------------- + +describe('BatchItemResult.code — backward compatibility', () => { + it('never attaches a code to a success entry', async () => { + mockFetch().mockResolvedValue( + rpcBatch([ + { jsonrpc: '2.0', id: 1, result: FIVE }, + { jsonrpc: '2.0', id: 2, error: { code: -32000, message: 'execution reverted' } }, + ]), + ); + + const results = await client.contracts.batchEthCall(twoCalls, RPC_URL); + + expect(results[0].code).toBeUndefined(); + expect(results[1].code).toBe(GuildPassErrorCode.HTTP_ERROR); + // `error` remains the sole human-readable channel and is untouched. + expect(results[0].error).toBeUndefined(); + expect(results[1].error).toBe('execution reverted'); + }); + + it('preserves codes across chunk boundaries', async () => { + // maxBatchSize 1 + chunk splits into two single-call batches; the code must + // survive the concatenation of per-chunk result arrays. + mockFetch() + .mockResolvedValueOnce(rpcBatch([{ jsonrpc: '2.0', id: 1, result: FIVE }])) + .mockResolvedValueOnce( + rpcBatch([ + { jsonrpc: '2.0', id: 1, error: { code: -32000, message: 'execution reverted' } }, + ]), + ); + + const results = await client.contracts.batchEthCall(twoCalls, RPC_URL, { + maxBatchSize: 1, + chunk: true, + }); + + expect(results).toEqual([ + { status: 'success', result: FIVE }, + { status: 'error', error: 'execution reverted', code: GuildPassErrorCode.HTTP_ERROR }, + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Helper units +// --------------------------------------------------------------------------- + +describe('toErrorCode', () => { + it('extracts the code from a GuildPassError', () => { + const err = new GuildPassError('nope', GuildPassErrorCode.RATE_LIMITED); + expect(toErrorCode(err)).toBe(GuildPassErrorCode.RATE_LIMITED); + }); + + it('recognises a cross-realm GuildPassError by shape', () => { + // A structurally identical error from a duplicated copy of the SDK fails + // `instanceof`; `isGuildPassError` is why the code still survives. + const foreign = { name: 'GuildPassNetworkError', code: 'TIMEOUT', message: 'slow' }; + expect(toErrorCode(foreign)).toBe(GuildPassErrorCode.TIMEOUT); + }); + + it.each([ + ['a plain Error', new Error('boom')], + ['a string', 'boom'], + ['null', null], + ['an error with an unknown code', { name: 'GuildPassError', code: 'NOT_A_REAL_CODE' }], + ])('falls back for %s', (_label, value) => { + expect(toErrorCode(value)).toBe(GuildPassErrorCode.UNKNOWN_ERROR); + expect(toErrorCode(value, GuildPassErrorCode.INVALID_RESPONSE)).toBe( + GuildPassErrorCode.INVALID_RESPONSE, + ); + }); + + it('falls back for a real GuildPassError carrying a code that does not exist', () => { + // `isGuildPassError` short-circuits on `instanceof`, so an error built with + // a phantom enum member (`GuildPassErrorCode.TYPO`, or a member removed + // since a custom contractProvider was compiled) reaches us carrying + // `undefined`. Returning that would break this function's own return type + // and hand callers an error entry with no usable classification. + const phantom = new GuildPassError('boom', undefined as unknown as GuildPassErrorCode); + + expect(phantom.code).toBeUndefined(); + expect(toErrorCode(phantom)).toBe(GuildPassErrorCode.UNKNOWN_ERROR); + expect(toErrorCode(phantom, GuildPassErrorCode.INVALID_RESPONSE)).toBe( + GuildPassErrorCode.INVALID_RESPONSE, + ); + }); + + it('only ever returns a real enum member', () => { + const valid = new Set(Object.values(GuildPassErrorCode)); + const inputs: unknown[] = [ + new GuildPassError('a', GuildPassErrorCode.TIMEOUT), + new GuildPassError('b', undefined as unknown as GuildPassErrorCode), + new GuildPassError('c', 'MADE_UP' as GuildPassErrorCode), + { name: 'GuildPassNetworkError', code: 'TIMEOUT' }, + { name: 'GuildPassError', code: 42 }, + new Error('plain'), + undefined, + ]; + + for (const input of inputs) { + expect(valid.has(toErrorCode(input))).toBe(true); + } + }); +}); + +describe('batchItemError', () => { + it('always populates status, error and code together', () => { + expect(batchItemError('boom', GuildPassErrorCode.HTTP_ERROR)).toEqual({ + status: 'error', + error: 'boom', + code: GuildPassErrorCode.HTTP_ERROR, + }); + }); +}); + +// --------------------------------------------------------------------------- +// Third-party contractProvider normalisation +// --------------------------------------------------------------------------- + +describe('BatchItemResult.code — third-party contractProvider entries', () => { + /** Builds a client whose provider returns exactly the given entries. */ + const clientReturning = (entries: unknown[]) => + new GuildPassClient({ + apiUrl: BASE_URL, + contractAddress: CONTRACT, + contractProvider: { + ethCall: async () => '0x', + batchEthCall: async () => entries, + } as never, + }); + + it('fills in UNKNOWN_ERROR when a provider omits code entirely', async () => { + const c = clientReturning([{ status: 'error', error: 'provider said no' }]); + + const results = await c.contracts.batchEthCall([twoCalls[0]]); + + expect(results[0]).toEqual({ + status: 'error', + error: 'provider said no', + code: GuildPassErrorCode.UNKNOWN_ERROR, + }); + }); + + it('replaces a code that is not a real enum member', async () => { + // A provider compiled against a different SDK version, carrying a code + // this build no longer knows about. + const c = clientReturning([{ status: 'error', error: 'stale', code: 'RETIRED_CODE' }]); + + const results = await c.contracts.batchEthCall([twoCalls[0]]); + + expect(results[0].code).toBe(GuildPassErrorCode.UNKNOWN_ERROR); + }); + + it('leaves a valid provider-supplied code untouched', async () => { + const c = clientReturning([ + { status: 'error', error: 'rate limited', code: GuildPassErrorCode.RATE_LIMITED }, + ]); + + const results = await c.contracts.batchEthCall([twoCalls[0]]); + + expect(results[0].code).toBe(GuildPassErrorCode.RATE_LIMITED); + }); + + it('normalises through the typed batch helpers too', async () => { + const c = clientReturning([{ status: 'error', error: 'provider said no' }]); + + const balances = await c.contracts.getMembershipTokenBalancesBatch({ + walletAddresses: [WALLET_A], + }); + expect(balances[0].code).toBe(GuildPassErrorCode.UNKNOWN_ERROR); + + const owners = await c.contracts.getGuildOwnersBatch({ guildIds: ['guild_1'] }); + expect(owners[0].code).toBe(GuildPassErrorCode.UNKNOWN_ERROR); + }); + + it('returns the provider array unchanged when nothing needs patching', async () => { + // Guards the allocation-free fast path: success-only results are passed + // through by reference rather than rebuilt. + const entries = [{ status: 'success' as const, result: FIVE }]; + const c = clientReturning(entries); + + const results = await c.contracts.batchEthCall([twoCalls[0]]); + + expect(results).toBe(entries); + }); +}); diff --git a/tests/contract-providers.test.ts b/tests/contract-providers.test.ts index 7e255d0..c66559f 100644 --- a/tests/contract-providers.test.ts +++ b/tests/contract-providers.test.ts @@ -248,7 +248,9 @@ describe('adapter batch semantics', () => { expect(results).toEqual([ { status: 'success', result: BALANCE_RESULT }, - { status: 'error', error: 'boom' }, + // The adapter's own `ethCall` wraps every provider failure as + // HTTP_ERROR, so the per-item entry inherits that classification. + { status: 'error', error: 'boom', code: GuildPassErrorCode.HTTP_ERROR }, ]); }); diff --git a/tests/contracts-consensus.test.ts b/tests/contracts-consensus.test.ts index 36d50e7..9b2c286 100644 --- a/tests/contracts-consensus.test.ts +++ b/tests/contracts-consensus.test.ts @@ -827,6 +827,7 @@ describe('batch consensus (per-item quorum)', () => { if (results[1].status === 'error') { expect(results[1].error).toMatch(/Consensus mismatch at batch index 1/); expect(results[1].error).toMatch(/quorum: 3/); + expect(results[1].code).toBe(GuildPassErrorCode.CONSENSUS_MISMATCH); } }); @@ -854,6 +855,7 @@ describe('batch consensus (per-item quorum)', () => { expect(results[0].status).toBe('error'); if (results[0].status === 'error') { expect(results[0].error).toMatch(/Consensus mismatch at batch index 0/); + expect(results[0].code).toBe(GuildPassErrorCode.CONSENSUS_MISMATCH); } }); diff --git a/tests/contracts.fuzz.test.ts b/tests/contracts.fuzz.test.ts index 7b32db4..3fe5d05 100644 --- a/tests/contracts.fuzz.test.ts +++ b/tests/contracts.fuzz.test.ts @@ -241,7 +241,11 @@ describe('decodeAggregate3 — structural hardening (#401)', () => { expect(results).toEqual([ { status: 'success', result: `0x${word(5)}` }, - { status: 'error', error: 'Reverted: Insufficient balance' }, + { + status: 'error', + error: 'Reverted: Insufficient balance', + code: GuildPassErrorCode.HTTP_ERROR, + }, ]); }); @@ -251,8 +255,16 @@ describe('decodeAggregate3 — structural hardening (#401)', () => { expect(decodeAggregate3(payload, 3)).toEqual([ { status: 'success', result: `0x${word(9)}` }, - { status: 'error', error: 'Missing Multicall3 result' }, - { status: 'error', error: 'Missing Multicall3 result' }, + { + status: 'error', + error: 'Missing Multicall3 result', + code: GuildPassErrorCode.INVALID_RESPONSE, + }, + { + status: 'error', + error: 'Missing Multicall3 result', + code: GuildPassErrorCode.INVALID_RESPONSE, + }, ]); }); @@ -272,7 +284,11 @@ describe('decodeAggregate3 — structural hardening (#401)', () => { revertBody; expect(decodeAggregate3(payload, 1)).toEqual([ - { status: 'error', error: 'Multicall3 item reverted' }, + { + status: 'error', + error: 'Multicall3 item reverted', + code: GuildPassErrorCode.HTTP_ERROR, + }, ]); }); }); @@ -381,5 +397,7 @@ describe('JSON-RPC batch correlation by id (#401 AC2)', () => { expect(results[0]).toEqual({ status: 'success', result: '5' }); expect(results[1].status).toBe('error'); + // An oversized item is an unusable response, not a contract-level failure. + expect(results[1].code).toBe(GuildPassErrorCode.INVALID_RESPONSE); }); }); diff --git a/tests/error-code-integrity.test.ts b/tests/error-code-integrity.test.ts new file mode 100644 index 0000000..2c45c3c --- /dev/null +++ b/tests/error-code-integrity.test.ts @@ -0,0 +1,88 @@ +/** + * Guards the integrity of `GuildPassErrorCode` itself. + * + * Every structured-error feature in the SDK — `isGuildPassError`, `toErrorCode`, + * `BatchItemResult.code`, and any consumer branching on `error.code` — assumes + * that `GuildPassErrorCode.SOMETHING` resolves to a real string at runtime. + * + * A TypeScript enum gives no protection against a member that was referenced + * but never declared: `GuildPassErrorCode.MISSING` is a *compile-time* error, + * but Vitest strips types without typechecking, so such a reference silently + * evaluates to `undefined` at runtime. An error then ships `code: undefined`, + * which defeats `isGuildPassError` for any error that has crossed a realm or a + * JSON round-trip — and a test asserting `code: GuildPassErrorCode.MISSING` + * compares `undefined` to `undefined` and passes while proving nothing. + * + * That is not hypothetical: `WS_CONNECTION_ERROR` was referenced at six sites + * in `webSocketProvider.ts` and asserted in ten tests without ever being + * declared. These tests fail loudly instead. + */ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { GuildPassErrorCode } from '../src/errors/errorCodes'; +import { GuildPassError } from '../src/errors/GuildPassError'; +import { isGuildPassError } from '../src/errors/guards'; + +const SRC_DIR = join(__dirname, '..', 'src'); + +/** Every `.ts` file under src/, recursively. */ +function sourceFiles(dir: string): string[] { + return readdirSync(dir).flatMap((entry) => { + const full = join(dir, entry); + if (statSync(full).isDirectory()) return sourceFiles(full); + return full.endsWith('.ts') ? [full] : []; + }); +} + +/** + * Strips block and line comments so that TSDoc prose naming a hypothetical + * code (`GuildPassErrorCode.TYPO` as an illustration) is not mistaken for a + * real reference. Only executable code can produce an `undefined` at runtime. + */ +function stripComments(source: string): string { + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); +} + +describe('GuildPassErrorCode integrity', () => { + it('every member resolves to its own name as a string', () => { + for (const [name, value] of Object.entries(GuildPassErrorCode)) { + expect(typeof value, `${name} must be a string`).toBe('string'); + // The enum is used as a wire/JSON value, so name and value must agree. + expect(value, `${name} must equal its own name`).toBe(name); + } + }); + + it('every GuildPassErrorCode.X referenced in src/ is a declared member', () => { + const declared = new Set(Object.keys(GuildPassErrorCode)); + const offenders: string[] = []; + + for (const file of sourceFiles(SRC_DIR)) { + const text = stripComments(readFileSync(file, 'utf8')); + for (const match of text.matchAll(/GuildPassErrorCode\.([A-Za-z_][A-Za-z0-9_]*)/g)) { + const member = match[1]; + if (!declared.has(member)) { + offenders.push(`${file.replace(SRC_DIR, 'src')} -> GuildPassErrorCode.${member}`); + } + } + } + + // A phantom member evaluates to `undefined` at runtime, so the error it + // builds carries no code at all. + expect(offenders).toEqual([]); + }); + + it('an error built from any member survives the cross-realm guard and JSON', () => { + // The `instanceof` fast path hides a missing code locally; these are the + // two routes where it actually bites. + for (const code of Object.values(GuildPassErrorCode)) { + const err = new GuildPassError('boom', code); + + const crossRealm = { name: err.name, code: err.code, message: err.message }; + expect(isGuildPassError(crossRealm), `${code} must survive a realm boundary`).toBe(true); + + const roundTripped = JSON.parse(JSON.stringify(err.toJSON())); + expect(roundTripped.code, `${code} must survive JSON`).toBe(code); + } + }); +}); diff --git a/tests/multicall3.test.ts b/tests/multicall3.test.ts index e10e9fa..5b3a10e 100644 --- a/tests/multicall3.test.ts +++ b/tests/multicall3.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { GuildPassClient } from '../src/client/GuildPassClient'; import { MULTICALL3_ADDRESS } from '../src/contracts/providers/adaptive.types'; +import { GuildPassErrorCode } from '../src/errors/errorCodes'; const BASE_URL = 'https://api.test.com'; const RPC_URL = 'https://rpc.test.com'; @@ -143,7 +144,11 @@ describe('Multicall3ContractProvider and aggregate3', () => { expect(results).toEqual([ { status: 'success', result: '5' }, - { status: 'error', error: 'Reverted: Insufficient balance' }, + { + status: 'error', + error: 'Reverted: Insufficient balance', + code: GuildPassErrorCode.HTTP_ERROR, + }, ]); });