Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased]

### Added
- **`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<GuildConfig>[]` in input order with per-guild failure isolation, matching the contract of `checkAccessBatch` and `getGuildOwnersBatch`.
- **`BatchItemResult` is now generic**, `BatchItemResult<T = string>`. 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`.
- Each guild is cached individually under `guilds:getGuildConfig:{guildId}`, so a batch call warms the cache for later single lookups and reuses entries already stored. In-flight deduplication is disabled inside the batch so one caller's failure or cancellation cannot affect another sharing a key.
- Throws `INVALID_INPUT` for a missing, non-array or empty `guildIds`, and for a `concurrency` outside `1..50`.
- **EIP-1271 smart-contract wallet support for SIWE** — resolves [#213](https://github.com/Adamantine-Guild/guildpass-sdk/issues/213). New `verifySiweSignatureAsync(params)` runs the same EIP-4361 checks as `verifySiweSignature` and, when the signature does not verify as an EOA signature, asks the claimed address's contract via `isValidSignature(bytes32,bytes)`. Safe, Argent and other account-abstraction wallets can now sign in.
- The fallback fires on **any** signature failure, not only a recovered-address mismatch. This is load-bearing: an EIP-1271 signature has no fixed length, so a multi-owner Safe signature is rejected by the 65-byte guard before ECDSA recovery is ever attempted — a fallback keyed on the mismatch alone would never reach a real contract wallet.
- Domain, nonce, expiry and `notBefore` failures stay terminal and are returned unchanged; a contract signature cannot rescue a message addressed to the wrong domain.
Expand Down
49 changes: 45 additions & 4 deletions api-report/guildpass-sdk.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ export class AdaptiveContractProvider implements ContractProvider {
export type AdaptiveHealthConfig = {
failureThreshold?: number;
cooldownMs?: number;
onCircuitOpen?: (url: string, openUntil: number) => void;
onCircuitClosed?: (url: string) => void;
latencyEmaAlpha?: number;
multicallPreferenceThreshold?: number;
};
Expand Down Expand Up @@ -174,6 +176,13 @@ export type AndRule = {
rules: AccessRule[];
};

// @public (undocumented)
export class ApiKeyAuthenticationProvider implements AuthenticationProvider {
constructor(apiKey: string);
// (undocumented)
getAuthorizationHeaders(): Record<string, string>;
}

// @public
export const areAddressesEqual: (addr1: string, addr2: string) => boolean;

Expand All @@ -185,6 +194,12 @@ export function assertValidRequest<T>(value: unknown, guard: ((value: unknown) =
// @public
export function assertValidResponse<T>(value: unknown, guard: ((value: unknown) => value is T) & Partial<ExplainingValidator<T>>, typeName: string, context?: ResponseValidationContext): T;

// @public (undocumented)
export interface AuthenticationProvider {
getAuthorizationHeaders(): Promise<Record<string, string>> | Record<string, string>;
onUnauthorized?(): Promise<boolean>;
}

// @public
export const BALANCE_OF_SELECTOR = "0x70a08231";

Expand All @@ -195,9 +210,9 @@ export type BatchEthCallItem = {
};

// @public
export type BatchItemResult = {
export type BatchItemResult<T = string> = {
status: 'success' | 'error';
result?: string;
result?: T;
error?: string;
};

Expand Down Expand Up @@ -282,6 +297,8 @@ export class ContractClient {
chunkConcurrency?: number;
}): Promise<BatchItemResult[]>;
getChainConfig(chainId?: number): ChainConfig;
// (undocumented)
getCircuitBreakerSnapshot(): Record<string, UrlHealth>;
getERC1155Balance(params: ERC1155BalanceParams, options?: RequestOptions): Promise<string>;
getERC20Balance(params: ERC20BalanceParams, options?: RequestOptions): Promise<string>;
getGuildOwner(params: GuildOwnerParams, options?: RequestOptions): Promise<string>;
Expand Down Expand Up @@ -573,6 +590,16 @@ export type GuildConfig = {
socialLinks?: Record<string, string>;
};

// @public
export type GuildConfigBatchOptions = {
concurrency?: number;
};

// @public
export type GuildConfigBatchParams = {
guildIds: string[];
};

// @public (undocumented)
export type GuildOwnerParams = {
guildId: string;
Expand Down Expand Up @@ -621,6 +648,10 @@ export class GuildPassClient {
clearCache(): Promise<void>;
// (undocumented)
readonly contracts: ContractClient;
// Warning: (ae-forgotten-export) The symbol "DiagnosticsModule" needs to be exported by the entry point index.d.ts
//
// (undocumented)
readonly diagnostics: DiagnosticsModule;
getConfig(): PublicClientConfig;
// (undocumented)
readonly guilds: GuildsService;
Expand All @@ -642,6 +673,8 @@ export class GuildPassClientBuilder {
// (undocumented)
withApiUrl(apiUrl: string): this;
// (undocumented)
withAuthProvider(authProvider: AuthenticationProvider): this;
// (undocumented)
withBatchStrategy(strategy: 'jsonrpc' | 'multicall3'): this;
// (undocumented)
withCache(cache: CacheAdapter, ttlMs?: number): this;
Expand Down Expand Up @@ -694,6 +727,7 @@ export type GuildPassClientConfig = {
multicallAddress?: string;
batchStrategy?: 'jsonrpc' | 'multicall3';
chains?: Record<number, ChainConfig>;
authProvider?: AuthenticationProvider;
apiKey?: string;
timeoutMs?: number;
defaultTimeoutMs?: number;
Expand Down Expand Up @@ -874,6 +908,7 @@ export class GuildsService {
}>;
// (undocumented)
getGuildConfig(params: GetGuildParams, options?: RequestOptions): Promise<GuildConfig>;
getGuildConfigBatch(params: GuildConfigBatchParams, options?: RequestOptions & GuildConfigBatchOptions): Promise<BatchItemResult<GuildConfig>[]>;
}

// @public
Expand Down Expand Up @@ -912,6 +947,8 @@ export class HealthTracker {
recordFailure(url: string, now?: number): void;
recordSuccess(url: string, latencyMs: number): void;
snapshot(url: string): Readonly<UrlHealth> | undefined;
// (undocumented)
snapshotAll(): Record<string, Readonly<UrlHealth>>;
}

// @public (undocumented)
Expand All @@ -926,6 +963,7 @@ export type HttpClientConfig = {
transport?: HttpTransport;
metadata?: ClientMetadata;
rateLimit?: RateLimitConfig;
authProvider?: AuthenticationProvider;
};

// @public (undocumented)
Expand Down Expand Up @@ -1186,8 +1224,11 @@ export type PaginatedResult<T> = {
// @public
export function parseSiweMessage(raw: string): SiweParseResult;

// @public (undocumented)
export const parseUnits: (value: string, decimals: number) => string;

// @public
export type PublicClientConfig = Omit<GuildPassClientConfig, 'apiKey' | 'fetch' | 'transport' | 'hooks' | 'contractProvider' | 'cache' | 'middleware'>;
export type PublicClientConfig = Omit<GuildPassClientConfig, 'apiKey' | 'fetch' | 'transport' | 'hooks' | 'contractProvider' | 'cache' | 'middleware' | 'authProvider'>;

// @public
export type ReadContractParams = {
Expand Down Expand Up @@ -1623,7 +1664,7 @@ export function verifyTypedDataSignature(domain: EIP712Domain, types: EIP712Type

// Warnings were encountered during analysis:
//
// dist/common-CabkBCAt.d.ts:224:5 - (ae-forgotten-export) The symbol "ClientMetadata" needs to be exported by the entry point index.d.ts
// dist/common-_RgT9NFP.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)

Expand Down
40 changes: 40 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,46 @@ Fetches full guild configuration.

- **Returns**: `Promise<GuildConfig>`

### `getGuildConfigBatch(params: { guildIds: string[] }, options?: RequestOptions & { concurrency?: number })`

Fetches configuration for several guilds in one call, with the same
order-preserving, per-item error isolation as the rest of the batch surface
(`checkAccessBatch`, `getGuildOwnersBatch`).

```typescript
const results = await client.guilds.getGuildConfigBatch({
guildIds: ['prime-guild', 'second-guild', 'missing-guild'],
});

results.forEach((entry, i) => {
if (entry.status === 'success') {
console.log(entry.result.theme);
} else {
console.error(`guild ${i} failed:`, entry.error);
}
});
```

- **Returns**: `Promise<BatchItemResult<GuildConfig>[]>` — one entry per input
guild ID, in input order. `BatchItemResult<T>` is the shape already used by the
contract batch methods; `T` defaults to `string` (raw hex) there, and is
parameterised to `GuildConfig` here.
- **Client-side fan-out.** There is no batch endpoint on the API: this issues one
`GET /guilds/:id/config` per ID through a bounded worker pool. It saves the
caller the orchestration, not the round trips.
- **Concurrency**: defaults to `5`, capped at `50`, matching `checkAccessBatch`.
Out-of-range values throw `INVALID_INPUT`.
- **Partial failures**: a guild that 404s or fails response validation becomes an
`'error'` entry; its siblings are unaffected and the batch still resolves.
- **Caching**: each guild is cached individually under
`guilds:getGuildConfig:{guildId}`, so a batch call warms the cache for later
single lookups and reuses entries a previous call already stored. In-flight
deduplication is deliberately disabled inside a batch, so one caller's failure
or cancellation cannot affect another sharing the same key.
- **Duplicate IDs** are preserved: each input position gets its own result.
- **Errors**: throws `INVALID_INPUT` when `guildIds` is missing, is not an array,
or is empty.

---

## Contract Module (`client.contracts`)
Expand Down
35 changes: 33 additions & 2 deletions src/client/GuildPassClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@
import type { AccessCheckParams, RoleAccessCheckParams, AccessCheckBatchOptions, AccessCheckBatchResult, AccessCheckBatchByResourceParams, AccessCheckBatchByResourceResult, AccessCheckResult } from '../access/access.types';
import type { MembershipParams } from '../membership/membership.types';
import type { GetRolesParams, GetUserRolesParams, HasRoleParams } from '../roles/roles.types';
import type { GetGuildParams } from '../guilds/guilds.types';
import type {
GetGuildParams,
GuildConfig,
GuildConfigBatchOptions,
GuildConfigBatchParams,
} from '../guilds/guilds.types';
import type { BatchItemResult } from '../contracts/contract.types';
import { DiagnosticsModule } from '../diagnostics/DiagnosticsModule';
import type { RequestOptions } from '../types/common';
import type { ResponseMetadata } from '../http/http.types';
Expand Down Expand Up @@ -90,7 +96,7 @@
private readonly cache: CacheAdapter | undefined;
private readonly cacheTtl: number | undefined;
private readonly deduplication: boolean;
private readonly inFlightRequests = new Map<string, Promise<any>>();

Check warning on line 99 in src/client/GuildPassClient.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type

// GuildPass SDK: Class member structure property or constructor.
constructor(config: GuildPassClientConfig) {
Expand Down Expand Up @@ -516,7 +522,7 @@
}

private buildCachedGuildsService(raw: GuildsService): GuildsService {
return Object.create(raw, {
const cached: GuildsService = Object.create(raw, {
getGuild: {
value: async <O extends RequestOptions & { includeMeta?: boolean }>(params: GetGuildParams, options?: O): Promise<O extends { includeMeta: true } ? { data: any; meta: ResponseMetadata } : any> => {
const key = buildCacheKey('guilds', 'getGuild', params.guildId);
Expand All @@ -530,6 +536,31 @@
},
},
});

// The batch method calls `this.getGuildConfig` internally. Left alone it
// would run against `raw` and bypass the cache entirely, so it is rebound to
// a view that still caches each guild but never coalesces in-flight
// requests — mirroring how `checkAccessBatch` is wired above. Coalescing
// inside a batch would let one caller's abort or failure affect an
// unrelated caller sharing the same key.
const neverCoalesce: GuildsService = Object.create(raw, {
getGuildConfig: {
value: async (params: GetGuildParams, options?: any): Promise<any> => {
const key = buildCacheKey('guilds', 'getGuildConfig', params.guildId);
return this.withCache(key, () => raw.getGuildConfig(params, options), undefined, false);
},
},
});

Object.defineProperty(cached, 'getGuildConfigBatch', {
value: async (
params: GuildConfigBatchParams,
options?: RequestOptions & GuildConfigBatchOptions,
): Promise<BatchItemResult<GuildConfig>[]> =>
raw.getGuildConfigBatch.call(neverCoalesce, params, options),
});

return cached;
}
// GuildPass SDK: End of logic containment structure block.
}
11 changes: 8 additions & 3 deletions src/contracts/contract.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,17 @@ export type BatchEthCallItem = {

/**
* Result of a single item in a batch response.
* On success, `status` is `'success'` and `result` contains the raw hex output.
* On success, `status` is `'success'` and `result` contains the item's value.
* On failure, `status` is `'error'` and `error` contains a descriptive message.
*
* `T` defaults to `string`, which is the raw hex output returned by the
* contract batch methods, so `BatchItemResult` on its own keeps meaning exactly
* what it always did. Batch methods that resolve richer values parameterise it
* instead — for example `BatchItemResult<GuildConfig>`.
*/
export type BatchItemResult = {
export type BatchItemResult<T = string> = {
status: 'success' | 'error';
result?: string;
result?: T;
error?: string;
};

Expand Down
90 changes: 89 additions & 1 deletion src/guilds/guilds.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@ import { verifySignedPayload, SignedEnvelope } from '../security';
import { GuildPassConfigError } from '../errors/errorTypes';
import { GuildPassErrorCode } from '../errors/errorCodes';
// GuildPass SDK: Import external module dependencies.
import { GetGuildParams, Guild, GuildConfig } from './guilds.types';
import {
GetGuildParams,
Guild,
GuildConfig,
GuildConfigBatchOptions,
GuildConfigBatchParams,
} from './guilds.types';
import type { BatchItemResult } from '../contracts/contract.types';

// GuildPass SDK: Core operational type definition.
export class GuildsService {
Expand Down Expand Up @@ -113,5 +120,86 @@ export class GuildsService {
return validatedResult;
// GuildPass SDK: End of logic containment structure block.
}

/**
* Fetches full configuration for several guilds in one call.
*
* This is a **client-side fan-out** over the existing single-guild
* `GET /guilds/:id/config` endpoint — there is no batch endpoint on the API —
* so it saves the caller the orchestration, not the round trips. Requests are
* issued through a bounded worker pool rather than all at once.
*
* Results are returned in the same order as `guildIds`, one entry per input,
* with per-guild failure isolation: a guild that 404s or whose response fails
* validation becomes an `'error'` entry and leaves its siblings untouched.
* This matches the contract of `getGuildOwnersBatch` and the rest of the
* batch surface.
*
* Duplicate IDs are preserved as-is: each input position gets its own result.
*
* @throws `INVALID_INPUT` when `guildIds` is missing, not an array, or empty,
* or when `concurrency` is outside `1..50`.
*/
public async getGuildConfigBatch(
params: GuildConfigBatchParams,
options?: RequestOptions & GuildConfigBatchOptions,
): Promise<BatchItemResult<GuildConfig>[]> {
const guildIds = params?.guildIds;

if (!Array.isArray(guildIds) || guildIds.length === 0) {
throw new GuildPassConfigError(
'guildIds array is required and must not be empty',
GuildPassErrorCode.INVALID_INPUT,
);
}

const concurrency = options?.concurrency ?? 5;
if (!Number.isInteger(concurrency) || concurrency < 1 || !Number.isFinite(concurrency)) {
throw new GuildPassConfigError(
'concurrency must be a positive finite integer',
GuildPassErrorCode.INVALID_INPUT,
);
}
if (concurrency > 50) {
throw new GuildPassConfigError(
'concurrency must not exceed 50',
GuildPassErrorCode.INVALID_INPUT,
);
}

const results: BatchItemResult<GuildConfig>[] = new Array(guildIds.length);
let next = 0;

const worker = async (): Promise<void> => {
// Index is claimed before awaiting, so each result lands at its own input
// position no matter what order the responses come back in.
while (next < guildIds.length) {
const index = next++;
try {
const config = await this.getGuildConfig(
{ guildId: guildIds[index] },
// `includeMeta` would change the resolved shape, so it is dropped:
// per-item metadata has nowhere to live in BatchItemResult.
options ? { ...options, includeMeta: false } : undefined,
);
results[index] = { status: 'success', result: config };
} catch (err) {
results[index] = {
status: 'error',
error: err instanceof Error ? err.message : 'Unknown error fetching guild config',
};
}
}
};

await Promise.all(
Array(Math.min(concurrency, guildIds.length))
.fill(null)
.map(() => worker()),
);

return results;
// GuildPass SDK: End of logic containment structure block.
}
// GuildPass SDK: End of logic containment structure block.
}
Loading
Loading