diff --git a/src/access/access.service.ts b/src/access/access.service.ts index 7bd11e4..6ee1282 100644 --- a/src/access/access.service.ts +++ b/src/access/access.service.ts @@ -33,7 +33,8 @@ export class AccessService { private readonly contracts?: ContractClient, private readonly onDiscrepancy?: (payload: DiscrepancyHookPayload) => void | Promise, private readonly verifySignedResponses = false, - private readonly trustedSignerAddress?: string + private readonly trustedSignerAddress?: string, + private readonly strictAddressChecksum = false, ) {} public async checkAccess(params: AccessCheckParams): Promise; @@ -41,7 +42,7 @@ export class AccessService { public async checkAccess(params: AccessCheckParams, options?: RequestOptions): Promise { const { walletAddress, guildId, resourceId } = params; - validateAddress(walletAddress); + validateAddress(walletAddress, { strict: this.strictAddressChecksum }); validateGuildId(guildId); validateResourceId(resourceId); @@ -188,7 +189,7 @@ export class AccessService { ): Promise { if (!Array.isArray(input)) { const { walletAddress, guildId, resourceIds } = input; - validateAddress(walletAddress); + validateAddress(walletAddress, { strict: this.strictAddressChecksum }); validateGuildId(guildId); if (!Array.isArray(resourceIds) || resourceIds.length === 0) { throw new GuildPassConfigError('resourceIds array must not be empty', GuildPassErrorCode.INVALID_INPUT); @@ -281,7 +282,7 @@ export class AccessService { // GuildPass SDK: Local block-scoped constant reference. const { walletAddress, guildId, roleId } = params; - validateAddress(walletAddress); + validateAddress(walletAddress, { strict: this.strictAddressChecksum }); validateGuildId(guildId); validateRoleId(roleId); @@ -315,4 +316,3 @@ export class AccessService { } // GuildPass SDK: End of logic containment structure block. } - diff --git a/src/client/GuildPassClient.ts b/src/client/GuildPassClient.ts index e5364dd..b628e25 100644 --- a/src/client/GuildPassClient.ts +++ b/src/client/GuildPassClient.ts @@ -118,6 +118,7 @@ export class GuildPassClient { ); const validateResponses = this.config.validateResponses ?? false; + const strictAddressChecksum = this.config.strictAddressChecksum ?? false; // IMPORTANT: Instantiate Contracts first so we can pass it to Access const rawContracts = new ContractClient(this.config, this.http); @@ -131,10 +132,11 @@ export class GuildPassClient { rawContracts, this.config.hooks?.onDiscrepancy, verifySignedResponses, - trustedSignerAddress + trustedSignerAddress, + strictAddressChecksum, ); - const rawMembership = new MembershipService(this.http, validateResponses); - const rawRoles = new RolesService(this.http, validateResponses, rawAccess); + const rawMembership = new MembershipService(this.http, validateResponses, strictAddressChecksum); + const rawRoles = new RolesService(this.http, validateResponses, rawAccess, strictAddressChecksum); const rawGuilds = new GuildsService( this.http, validateResponses, @@ -191,7 +193,7 @@ export class GuildPassClient { * Useful when a wallet's on-chain state has changed (e.g., token transfer). */ public async invalidateWalletCache(walletAddress: string): Promise { - validateAddress(walletAddress); + validateAddress(walletAddress, { strict: this.config.strictAddressChecksum }); if (!this.cache) return; const wallet = normaliseAddress(walletAddress); try { diff --git a/src/config/defaultConfig.ts b/src/config/defaultConfig.ts index e51b7f9..68f1adf 100644 --- a/src/config/defaultConfig.ts +++ b/src/config/defaultConfig.ts @@ -7,5 +7,6 @@ export const DEFAULT_CONFIG: Partial = { chainId: 1, // Ethereum Mainnet timeoutMs: 10000, // 10 seconds validateResponses: false, + strictAddressChecksum: false, // GuildPass SDK: End of logic containment structure block. }; diff --git a/src/config/sdkConfig.ts b/src/config/sdkConfig.ts index eaed1a5..9dbc5fc 100644 --- a/src/config/sdkConfig.ts +++ b/src/config/sdkConfig.ts @@ -53,6 +53,8 @@ export type GuildPassClientConfig = { fetch?: FetchLike; rateLimit?: RateLimitConfig; validateResponses?: boolean; + /** Enforces EIP-55 checksums for all addresses accepted by the SDK. @default false */ + strictAddressChecksum?: boolean; cache?: CacheAdapter; cacheTtl?: number; /** @@ -169,6 +171,10 @@ export function validateConfig(config: GuildPassClientConfig): void { throwConfigError('deduplication must be a boolean', 'deduplication', 'invalid_type', config.deduplication); } + if (config.strictAddressChecksum !== undefined && typeof config.strictAddressChecksum !== 'boolean') { + throwConfigError('strictAddressChecksum must be a boolean', 'strictAddressChecksum', 'invalid_type', config.strictAddressChecksum); + } + if (config.contractProvider !== undefined) { const provider = config.contractProvider; const required = ['ethCall', 'batchEthCall'] as const; @@ -185,7 +191,7 @@ export function validateConfig(config: GuildPassClientConfig): void { if (config.multicallAddress !== undefined) { try { - validateAddress(config.multicallAddress); + validateAddress(config.multicallAddress, { strict: config.strictAddressChecksum }); } catch { throw new GuildPassConfigError( 'Invalid multicallAddress: expected a valid EVM address', @@ -215,7 +221,7 @@ export function validateConfig(config: GuildPassClientConfig): void { if (config.trustedSignerAddress !== undefined) { try { - validateAddress(config.trustedSignerAddress); + validateAddress(config.trustedSignerAddress, { strict: config.strictAddressChecksum }); } catch { throw new GuildPassConfigError( 'Invalid trustedSignerAddress: expected a valid EVM address', @@ -321,7 +327,7 @@ export function validateConfig(config: GuildPassClientConfig): void { validateContractReadConsensus(config.contractReadConsensus); - validateChainsConfig(config.chains); + validateChainsConfig(config.chains, config.strictAddressChecksum); const transport = config.fetch ?? globalThis.fetch; if (typeof transport !== 'function') { @@ -329,7 +335,7 @@ export function validateConfig(config: GuildPassClientConfig): void { } } -function validateChainsConfig(chains?: Record): void { +function validateChainsConfig(chains?: Record, strictAddressChecksum = false): void { if (!chains) return; for (const [chainIdKey, chainConfig] of Object.entries(chains)) { @@ -374,7 +380,7 @@ function validateChainsConfig(chains?: Record): void { if (chainConfig.contractAddress !== undefined) { try { - validateAddress(chainConfig.contractAddress); + validateAddress(chainConfig.contractAddress, { strict: strictAddressChecksum }); } catch (err: any) { throw new GuildPassConfigError( `Invalid chains[${chainIdKey}].contractAddress: expected a valid EVM address`, @@ -392,7 +398,7 @@ function validateChainsConfig(chains?: Record): void { if (chainConfig.multicallAddress !== undefined) { try { - validateAddress(chainConfig.multicallAddress); + validateAddress(chainConfig.multicallAddress, { strict: strictAddressChecksum }); } catch (err: any) { throw new GuildPassConfigError( `Invalid chains[${chainIdKey}].multicallAddress: expected a valid EVM address`, @@ -545,4 +551,4 @@ export function resolveChainConfig(config: GuildPassClientConfig, chainId: numbe contractAddress: config.contractAddress, multicallAddress: config.multicallAddress, }; -} \ No newline at end of file +} diff --git a/src/contracts/contractClient.ts b/src/contracts/contractClient.ts index d85766e..fe541bb 100644 --- a/src/contracts/contractClient.ts +++ b/src/contracts/contractClient.ts @@ -423,7 +423,7 @@ export class ContractClient { const chainConfig = this.getChainConfig(chainId); const contractAddress = params.contractAddress ?? chainConfig.contractAddress; - validateAddress(walletAddress); + validateAddress(walletAddress, { strict: this.config.strictAddressChecksum }); if (!contractAddress) { throw new GuildPassConfigError( @@ -432,7 +432,7 @@ export class ContractClient { ); } - validateAddress(contractAddress); + validateAddress(contractAddress, { strict: this.config.strictAddressChecksum }); const data = `${BALANCE_OF_SELECTOR}${encodeAddressArgument(walletAddress)}`; const result = await this.resolveSingleEthCall( @@ -466,7 +466,7 @@ export class ContractClient { GuildPassErrorCode.INVALID_CONFIG, ); } - validateAddress(contractAddress); + validateAddress(contractAddress, { strict: this.config.strictAddressChecksum }); const result = await provider.ethCall({ to: contractAddress, data: DECIMALS_SELECTOR }, options); @@ -518,7 +518,7 @@ export class ContractClient { options?: RequestOptions, ): Promise { const { walletAddress, contractAddress } = params; - validateAddress(walletAddress); + validateAddress(walletAddress, { strict: this.config.strictAddressChecksum }); // Collect every chain ID we should query. const chainIds: number[] = []; @@ -578,8 +578,8 @@ export class ContractClient { const { walletAddress, chainId, contractAddress } = params; const chainConfig = this.getChainConfig(chainId); - validateAddress(walletAddress); - validateAddress(contractAddress); + validateAddress(walletAddress, { strict: this.config.strictAddressChecksum }); + validateAddress(contractAddress, { strict: this.config.strictAddressChecksum }); const data = `${BALANCE_OF_SELECTOR}${encodeAddressArgument(walletAddress)}`; const result = await this.resolveSingleEthCall( @@ -608,8 +608,8 @@ export class ContractClient { const { walletAddress, tokenId, chainId, contractAddress } = params; const chainConfig = this.getChainConfig(chainId); - validateAddress(walletAddress); - validateAddress(contractAddress); + validateAddress(walletAddress, { strict: this.config.strictAddressChecksum }); + validateAddress(contractAddress, { strict: this.config.strictAddressChecksum }); const data = `${ERC721_OWNER_OF_SELECTOR}${encodeUint256Argument(tokenId, 'tokenId')}`; const result = await this.resolveSingleEthCall( @@ -638,8 +638,8 @@ export class ContractClient { const { walletAddress, tokenId, chainId, contractAddress } = params; const chainConfig = this.getChainConfig(chainId); - validateAddress(walletAddress); - validateAddress(contractAddress); + validateAddress(walletAddress, { strict: this.config.strictAddressChecksum }); + validateAddress(contractAddress, { strict: this.config.strictAddressChecksum }); const data = `${ERC1155_BALANCE_OF_SELECTOR}${encodeAddressArgument(walletAddress)}${encodeUint256Argument(tokenId, 'tokenId')}`; const result = await this.resolveSingleEthCall( @@ -682,7 +682,7 @@ export class ContractClient { ); } - validateAddress(contractAddress); + validateAddress(contractAddress, { strict: this.config.strictAddressChecksum }); const signature = buildFunctionSignature(abi); const selector = getFunctionSelector(signature); @@ -728,7 +728,7 @@ export class ContractClient { ); } - validateAddress(contractAddress); + validateAddress(contractAddress, { strict: this.config.strictAddressChecksum }); const data = `${GET_GUILD_OWNER_SELECTOR}${encodeGuildId(guildId)}`; const result = await this.resolveSingleEthCall( @@ -1213,7 +1213,7 @@ export class ContractClient { GuildPassErrorCode.INVALID_INPUT, ); } - validateAddress(call.to); + validateAddress(call.to, { strict: this.config.strictAddressChecksum }); } // Resolve precedence at the leaf path so chunked recursion also routes @@ -1261,7 +1261,7 @@ export class ContractClient { // Validate all addresses upfront for (const addr of walletAddresses) { - validateAddress(addr); + validateAddress(addr, { strict: this.config.strictAddressChecksum }); } const chainConfig = this.getChainConfig(chainId); @@ -1285,7 +1285,7 @@ export class ContractClient { ); } - validateAddress(contractAddress); + validateAddress(contractAddress, { strict: this.config.strictAddressChecksum }); // Build the batch calls const calls: BatchEthCallItem[] = walletAddresses.map((addr) => ({ @@ -1370,7 +1370,7 @@ export class ContractClient { ); } - validateAddress(contractAddress); + validateAddress(contractAddress, { strict: this.config.strictAddressChecksum }); // Build the batch calls const calls: BatchEthCallItem[] = guildIds.map((gid) => ({ diff --git a/src/membership/membership.service.ts b/src/membership/membership.service.ts index 4ab8c6b..aff250f 100644 --- a/src/membership/membership.service.ts +++ b/src/membership/membership.service.ts @@ -16,6 +16,7 @@ export class MembershipService { constructor( private readonly http: HttpClient, private readonly validateResponses = false, + private readonly strictAddressChecksum = false, ) {} /** @@ -36,7 +37,7 @@ export class MembershipService { // GuildPass SDK: Local block-scoped constant reference. const { walletAddress, guildId } = params; - validateAddress(walletAddress); + validateAddress(walletAddress, { strict: this.strictAddressChecksum }); validateGuildId(guildId); // GuildPass SDK: Terminate function block execution and return. diff --git a/src/roles/roles.service.ts b/src/roles/roles.service.ts index f702186..cd221df 100644 --- a/src/roles/roles.service.ts +++ b/src/roles/roles.service.ts @@ -21,6 +21,7 @@ export class RolesService { private readonly http: HttpClient, private readonly validateResponses = false, private readonly access?: AccessService, + private readonly strictAddressChecksum = false, ) {} /** @@ -59,7 +60,7 @@ export class RolesService { public async getUserRoles(params: GetUserRolesParams, options?: RequestOptions): Promise { const { walletAddress, guildId, cursor, limit } = params; - validateAddress(walletAddress); + validateAddress(walletAddress, { strict: this.strictAddressChecksum }); validateGuildId(guildId); const path = `/guilds/${encodePathSegment(guildId)}/members/${encodePathSegment(normaliseAddress(walletAddress))}/roles`; diff --git a/tests/services.test.ts b/tests/services.test.ts index 2a6ab6f..0411d15 100644 --- a/tests/services.test.ts +++ b/tests/services.test.ts @@ -969,3 +969,48 @@ describe('Service Modules', () => { }); }); }); + +describe('strictAddressChecksum', () => { + const nonChecksummedAddress = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'; + + it('rejects non-checksummed addresses in AccessService', async () => { + const client = new GuildPassClient({ + apiUrl: 'https://api.test.com', + strictAddressChecksum: true, + }); + + await expect(client.access.checkAccess({ + walletAddress: nonChecksummedAddress, + guildId: 'guild_1', + resourceId: 'res_1', + })).rejects.toMatchObject({ code: GuildPassErrorCode.INVALID_ADDRESS }); + }); + + it('rejects non-checksummed addresses in MembershipService', async () => { + const client = new GuildPassClient({ + apiUrl: 'https://api.test.com', + strictAddressChecksum: true, + }); + + await expect(client.membership.getMembership({ + walletAddress: nonChecksummedAddress, + guildId: 'guild_1', + })).rejects.toMatchObject({ code: GuildPassErrorCode.INVALID_ADDRESS }); + }); + + it('accepts non-checksummed addresses by default', async () => { + const fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ hasAccess: true }), + headers: new Headers(), + }); + const client = new GuildPassClient({ apiUrl: 'https://api.test.com', fetch }); + + await expect(client.access.checkAccess({ + walletAddress: nonChecksummedAddress, + guildId: 'guild_1', + resourceId: 'res_1', + })).resolves.toEqual({ hasAccess: true }); + }); +});