Skip to content
Open
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
216 changes: 62 additions & 154 deletions src/config/sdkConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { CacheAdapter } from '../cache/cache.types';
import { ChainConfig } from '../contracts/contract.types';
import { validateAddress } from '../utils/validation';

// GuildPass SDK: Exported component definition.
export type GuildPassClientConfig = {
apiUrl: string;
chainId?: number;
Expand All @@ -19,66 +18,31 @@ export type GuildPassClientConfig = {
/** Global retry policy applied to all requests. Defaults to no retries. */
retry?: RetryConfig;
hooks?: HttpHooks;
/**
* Optional fetch-compatible transport for tests, tracing, proxies,
* custom runtimes, or environments without globalThis.fetch.
*/
fetch?: FetchLike;
/**
* When true, service responses are checked against runtime shape guards
* before being returned, throwing a GuildPassError with code
* INVALID_RESPONSE if the API response is malformed. Defaults to false
* to preserve existing behaviour.
*/
validateResponses?: boolean;
/**
* A cache adapter used to memoize read responses.
*
* Provide `new InMemoryCacheAdapter()` for a built-in solution, or supply
* any object that satisfies the {@link CacheAdapter} interface (e.g. a
* Redis adapter) for distributed caching.
*
* @example
* ```typescript
* import { GuildPassClient, InMemoryCacheAdapter } from '@guildpass/sdk';
*
* const client = new GuildPassClient({
* apiUrl: 'https://api.guildpass.xyz',
* cache: new InMemoryCacheAdapter(),
* cacheTtl: 60_000,
* });
* ```
*/
cache?: CacheAdapter;
/**
* Default TTL in **milliseconds** applied to every cached entry when
* a per-call TTL is not specified. Defaults to `0` (no expiry).
*/
cacheTtl?: number;
/**
* Whether to send client metadata headers (`X-GuildPass-SDK-Version`,
* `X-GuildPass-Client`) on GuildPass API-relative requests.
*
* Defaults to `true`. Set to `false` to disable all metadata headers.
* Metadata headers never include API keys, wallet secrets, or tokens.
*/
sendClientMetadata?: boolean;
/**
* Optional client or integration name (e.g. `"my-dapp"`, `"discord-bot"`).
* Appears in the `X-GuildPass-Client` header alongside the SDK version.
*/
clientName?: string;
/**
* Optional client version string sent as part of `X-GuildPass-Client`.
* When omitted, only the client name is sent (if provided).
*/
clientVersion?: string;
// GuildPass SDK: End of logic containment structure block.
};

/**
* Local helper to enforce structured configuration exceptions uniformly.
*/
const throwConfigError = (message: string, field: string, reason: string, value: any): never => {
const isSensitive = ['apikey', 'secret', 'privatekey', 'password'].includes(field.toLowerCase());
throw new GuildPassError(message, GuildPassErrorCode.INVALID_CONFIG, undefined, {
field,
reason,
...(isSensitive ? {} : { value }),
valueType: typeof value,
});
};

export function validateConfig(config: GuildPassClientConfig): void {
if (!config.apiUrl) {
throw new GuildPassError('apiUrl is required', GuildPassErrorCode.INVALID_CONFIG);
throwConfigError('apiUrl is required', 'apiUrl', 'REQUIRED', config.apiUrl);
}

try {
Expand All @@ -87,172 +51,114 @@ export function validateConfig(config: GuildPassClientConfig): void {
throw new Error();
}
} catch {
throw new GuildPassError(
`Invalid apiUrl: "${config.apiUrl}"`,
GuildPassErrorCode.INVALID_CONFIG,
);
throwConfigError(`Invalid apiUrl: "${config.apiUrl}"`, 'apiUrl', 'INVALID_FORMAT', config.apiUrl);
}

if (
config.timeoutMs !== undefined &&
(typeof config.timeoutMs !== 'number' || config.timeoutMs <= 0)
) {
throw new GuildPassError(
'timeoutMs must be a positive number',
GuildPassErrorCode.INVALID_CONFIG,
);
if (config.timeoutMs !== undefined && (typeof config.timeoutMs !== 'number' || config.timeoutMs <= 0)) {
throwConfigError('timeoutMs must be a positive number', 'timeoutMs', 'INVALID_FORMAT', config.timeoutMs);
}

// INSERT CACHE VALIDATION
if (
config.cacheTtl !== undefined &&
(typeof config.cacheTtl !== 'number' || config.cacheTtl < 0 || !Number.isFinite(config.cacheTtl))
) {
throw new GuildPassError(
'cacheTtl must be a non-negative finite number (milliseconds)',
GuildPassErrorCode.INVALID_CONFIG,
);
if (config.cacheTtl !== undefined && (typeof config.cacheTtl !== 'number' || config.cacheTtl < 0 || !Number.isFinite(config.cacheTtl))) {
throwConfigError('cacheTtl must be a non-negative finite number (milliseconds)', 'cacheTtl', 'INVALID_FORMAT', config.cacheTtl);
}

if (config.cache !== undefined) {
const adapter = config.cache;
const required = ['get', 'set', 'delete', 'clear'] as const;
for (const method of required) {
if (typeof adapter[method] !== 'function') {
throw new GuildPassError(
`cache adapter must implement ${method}(): function`,
GuildPassErrorCode.INVALID_CONFIG,
);
throwConfigError(`cache adapter must implement ${method}(): function`, 'cache', 'INVALID_TYPE', config.cache);
}
}
}
// END CACHE VALIDATION

// INSERT METADATA VALIDATION
if (
config.sendClientMetadata !== undefined &&
typeof config.sendClientMetadata !== 'boolean'
) {
throw new GuildPassError(
'sendClientMetadata must be a boolean',
GuildPassErrorCode.INVALID_CONFIG,
);
if (config.sendClientMetadata !== undefined && typeof config.sendClientMetadata !== 'boolean') {
throwConfigError('sendClientMetadata must be a boolean', 'sendClientMetadata', 'INVALID_TYPE', config.sendClientMetadata);
}

if (
config.clientName !== undefined &&
typeof config.clientName !== 'string'
) {
throw new GuildPassError(
'clientName must be a string',
GuildPassErrorCode.INVALID_CONFIG,
);
if (config.clientName !== undefined && typeof config.clientName !== 'string') {
throwConfigError('clientName must be a string', 'clientName', 'INVALID_TYPE', config.clientName);
}

if (
config.clientVersion !== undefined &&
typeof config.clientVersion !== 'string'
) {
throw new GuildPassError(
'clientVersion must be a string',
GuildPassErrorCode.INVALID_CONFIG,
);
if (config.clientVersion !== undefined && typeof config.clientVersion !== 'string') {
throwConfigError('clientVersion must be a string', 'clientVersion', 'INVALID_TYPE', config.clientVersion);
}
// END METADATA VALIDATION

// INSERT RETRY VALIDATION
if (config.retry) {
const r = config.retry;

if (r.maxRetries !== undefined && (typeof r.maxRetries !== 'number' || !Number.isFinite(r.maxRetries) || r.maxRetries < 0)) {
throw new GuildPassError('retry.maxRetries must be a non-negative finite number', GuildPassErrorCode.INVALID_CONFIG);
throwConfigError('retry.maxRetries must be a non-negative finite number', 'retry.maxRetries', 'INVALID_FORMAT', r.maxRetries);
}

if (r.baseDelayMs !== undefined && (typeof r.baseDelayMs !== 'number' || !Number.isFinite(r.baseDelayMs) || r.baseDelayMs < 0)) {
throw new GuildPassError('retry.baseDelayMs must be a non-negative finite number', GuildPassErrorCode.INVALID_CONFIG);
throwConfigError('retry.baseDelayMs must be a non-negative finite number', 'retry.baseDelayMs', 'INVALID_FORMAT', r.baseDelayMs);
}

if (r.maxDelayMs !== undefined && (typeof r.maxDelayMs !== 'number' || !Number.isFinite(r.maxDelayMs) || r.maxDelayMs < 0)) {
throw new GuildPassError('retry.maxDelayMs must be a non-negative finite number', GuildPassErrorCode.INVALID_CONFIG);
throwConfigError('retry.maxDelayMs must be a non-negative finite number', 'retry.maxDelayMs', 'INVALID_FORMAT', r.maxDelayMs);
}

if (r.baseDelayMs !== undefined && r.maxDelayMs !== undefined && r.maxDelayMs < r.baseDelayMs) {
throw new GuildPassError('retry.maxDelayMs cannot be less than baseDelayMs', GuildPassErrorCode.INVALID_CONFIG);
throwConfigError('retry.maxDelayMs cannot be less than baseDelayMs', 'retry.maxDelayMs', 'INVALID_FORMAT', r.maxDelayMs);
}

if (r.retryableStatuses !== undefined && (!Array.isArray(r.retryableStatuses) || r.retryableStatuses.length === 0 || r.retryableStatuses.some((s) => typeof s !== 'number' || !Number.isFinite(s)))) {
throw new GuildPassError('retryableStatuses must be a non-empty array of valid HTTP status numbers', GuildPassErrorCode.INVALID_CONFIG);
throwConfigError('retryableStatuses must be a non-empty array of valid HTTP status numbers', 'retry.retryableStatuses', 'INVALID_FORMAT', r.retryableStatuses);
}
}
// END RETRY VALIDATION

if (config.apiKey !== undefined && typeof config.apiKey !== 'string') {
throwConfigError('apiKey must be a string', 'apiKey', 'INVALID_TYPE', config.apiKey);
}

validateChainsConfig(config.chains);

const transport = config.fetch ?? globalThis.fetch;
if (typeof transport !== 'function') {
throw new GuildPassError(
'A fetch-compatible transport is required. Provide config.fetch or use a runtime with globalThis.fetch.',
GuildPassErrorCode.INVALID_CONFIG,
);
throwConfigError('A fetch-compatible transport is required.', 'fetch', 'REQUIRED', null);
}
}

function validateChainsConfig(chains?: Record<number, ChainConfig>): void {
if (!chains) {
return;
}
if (!chains) return;

for (const [chainIdKey, chainConfig] of Object.entries(chains)) {
const chainId = Number(chainIdKey);

if (!Number.isSafeInteger(chainId) || chainId <= 0 || String(chainId) !== chainIdKey) {
throw new GuildPassError(
`Invalid chains[${chainIdKey}]: chain ID must be a positive safe integer`,
GuildPassErrorCode.INVALID_CONFIG,
);
throwConfigError(`Invalid chains[${chainIdKey}]: chain ID must be a positive safe integer`, `chains.${chainIdKey}`, 'INVALID_FORMAT', chainIdKey);
}

if (chainConfig.rpcUrl !== undefined) {
validateChainRpcUrl(chainIdKey, chainConfig.rpcUrl);
try {
const url = new URL(chainConfig.rpcUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error();
} catch {
throwConfigError(`Invalid chains[${chainIdKey}].rpcUrl: expected an http or https URL`, `chains.${chainIdKey}.rpcUrl`, 'INVALID_FORMAT', chainConfig.rpcUrl);
}
}

if (chainConfig.contractAddress !== undefined) {
validateChainContractAddress(chainIdKey, chainConfig.contractAddress);
}
}
}

function validateChainRpcUrl(chainIdKey: string, rpcUrl: string): void {
try {
const url = new URL(rpcUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error();
try {
validateAddress(chainConfig.contractAddress);
} catch (err: any) {
throw new GuildPassError(
`Invalid chains[${chainIdKey}].contractAddress: expected a valid EVM address`,
GuildPassErrorCode.INVALID_CONFIG,
undefined,
{
field: `chains.${chainIdKey}.contractAddress`,
reason: 'INVALID_FORMAT',
value: chainConfig.contractAddress,
valueType: 'string',
}
);
}
}
} catch {
throw new GuildPassError(
`Invalid chains[${chainIdKey}].rpcUrl: expected an http or https URL`,
GuildPassErrorCode.INVALID_CONFIG,
);
}
}

function validateChainContractAddress(chainIdKey: string, contractAddress: string): void {
try {
validateAddress(contractAddress);
} catch {
throw new GuildPassError(
`Invalid chains[${chainIdKey}].contractAddress: expected a valid EVM address`,
GuildPassErrorCode.INVALID_CONFIG,
);
}
}
/**
* Resolves the chain configuration for a given chain ID.
* Per-chain entries in `config.chains` take precedence over the top-level
* `rpcUrl` / `contractAddress` fallbacks.
* Throws `INVALID_CONFIG` only when a `chains` map is provided but does not
* contain an entry for the requested chain.
*/
export function resolveChainConfig(config: GuildPassClientConfig, chainId: number): ChainConfig {
if (config.chains) {
if (Object.prototype.hasOwnProperty.call(config.chains, chainId)) {
Expand All @@ -261,7 +167,9 @@ export function resolveChainConfig(config: GuildPassClientConfig, chainId: numbe
throw new GuildPassError(
`No configuration found for chain ID ${chainId}`,
GuildPassErrorCode.INVALID_CONFIG,
undefined,
{ field: 'chainId', reason: 'NOT_FOUND', value: chainId, valueType: 'number' }
);
}
return { rpcUrl: config.rpcUrl, contractAddress: config.contractAddress };
}
}
25 changes: 25 additions & 0 deletions src/contracts/contractClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import {
BALANCE_OF_SELECTOR,
GET_GUILD_OWNER_SELECTOR,
DECIMALS_SELECTOR, // <-- ADD THIS IMPORT
HEX_32_BYTES_LENGTH,
decodeAddressResult,
decodeUint256Result,
Expand All @@ -30,16 +31,40 @@ import { GuildPassClientConfig, resolveChainConfig } from '../config/sdkConfig';
import { HttpClient } from '../http/httpClient';
import { RequestOptions } from '../types/common';

// Local pure helper function for exact decimal string shift math
export const formatUnits = (value: string, decimals: number): string => {
// Guard against negative decimal counts or non-integers
if (decimals < 0 || !Number.isInteger(decimals)) {
throw new Error('Decimals must be a non-negative integer');
}

// Guard against invalid base unit numeric strings (letters or pre-existing decimals)
if (!/^\d+$/.test(value)) {
throw new Error('Value must be a valid big integer string containing only digits');
}

if (value === '0' || !value) return '0';

const padded = value.padStart(decimals + 1, '0');
const loc = padded.length - decimals;
const whole = padded.slice(0, loc);
let fraction = padded.slice(loc).replace(/0+$/, '');
return fraction ? `${whole}.${fraction}` : whole;
};

export {
BALANCE_OF_SELECTOR,
GET_GUILD_OWNER_SELECTOR,
DECIMALS_SELECTOR, // <-- ADD THIS EXPORT
HEX_32_BYTES_LENGTH,
decodeAddressResult,
decodeUint256Result,
encodeAddressArgument,
encodeGuildId,
};



type JsonRpcSuccess = {
result?: unknown;
};
Expand Down
1 change: 1 addition & 0 deletions src/contracts/contractHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const BALANCE_OF_SELECTOR = '0x70a08231';
export const ERC721_OWNER_OF_SELECTOR = '0x6352211e';
/** OpenZeppelin AccessControl `hasRole(bytes32,address)`. */
export const HAS_ROLE_SELECTOR = '0x91d14854';
export const DECIMALS_SELECTOR = '0x313ce567'; // 4-byte signature for decimals()

export const HEX_32_BYTES_LENGTH = 64;

Expand Down
Empty file added src/errors/error.types.ts
Empty file.
Empty file added src/utils/errorTypes.ts
Empty file.
Loading