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
9 changes: 9 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]

### Changed
- **`validateAddress` now verifies the EIP-55 checksum automatically for mixed-case addresses** — resolves [#394](https://github.com/Adamantine-Guild/guildpass-sdk/issues/394). Previously the checksum was only checked under `{ strict: true }`, so a mixed-case address with a corrupted checksum (a typo, a truncated copy-paste, a tampered value) passed validation silently.
- Detection follows the intent of EIP-55: only a mixed-case hex payload carries checksum information, so only that case is verified. An all-lowercase or all-uppercase address carries none and is accepted exactly as before — no regression for the common lowercase path.
- `{ strict: true }` is unchanged and still forces the check on any casing, including all-lowercase.
- Failures keep the existing error shape: `GuildPassErrorCode.INVALID_ADDRESS` with `reason: 'checksum_failed'`.
- This is a behavior change only for input that is mixed-case *and* fails its checksum — input that was previously accepted by mistake. Any address a wallet or block explorer produces is correctly checksummed and keeps working.
- **`validateResponses` now defaults to `true`** (was `false`). Every response from `AccessService.checkAccess`/`checkRoleAccess`, `MembershipService.getMembership`, `RolesService.getRoles`/`getUserRoles`, and `GuildsService.getGuild`/`getGuildConfig` is now shape-checked before being returned, out of the box — no config needed. Set `validateResponses: false` on `GuildPassClientConfig` to restore the previous unchecked behavior.
- Unknown/extra response fields are always passed through untouched (never stripped, never rejected) — see the passthrough policy in [`docs/serialization-validation.md`](docs/serialization-validation.md). Only a missing required field or a wrong primitive type fails validation.
- Fixed two over-strict validation bugs found while making this the default, both of which would have rejected common, legitimate API responses:
Expand All @@ -15,6 +20,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- This is a behavior change for any consumer relying on receiving a malformed/incomplete API response unchecked; per this project's pre-1.0 versioning policy (see README → Versioning) this ships as a **minor** bump.

### Added
- **`normaliseAddress(address, { checksum })`** — opt-in EIP-55 checksummed output, part of [#394](https://github.com/Adamantine-Guild/guildpass-sdk/issues/394). `normaliseAddress(addr, { checksum: true })` returns the checksummed form for display; the default (no options) still returns lowercase and is unchanged.
- The lowercase default is deliberate and load-bearing: `GuildPassClient` builds cache keys from `normaliseAddress`, and `areAddressesEqual` compares through it. Emitting mixed-case by default would split cache entries for the same wallet across casings.
- Purely additive — the parameter is optional, so every existing call site keeps its current behavior and type.
- No new runtime dependency: reuses the `js-sha3` keccak-256 already used by `toChecksumAddress`.
- **Unified request/response validation layer.** Every HTTP domain service call (`AccessService.checkAccess`/`checkRoleAccess`, `MembershipService.getMembership`, `RolesService.getRoles`/`getUserRoles`, `GuildsService.getGuild`/`getGuildConfig`) now validates its request parameters structurally before transmission, pairing the response-shape guards that already existed for each model. See [`docs/serialization-validation.md`](docs/serialization-validation.md) for the schema/error/unknown-field policy.
- New: `assertValidRequest`, and per-model guards `isAccessCheckParams`, `isRoleAccessCheckParams`, `isMembershipParams`, `isGetRolesParams`, `isGetUserRolesParams`, `isGetGuildParams` (exported from the root package, mirroring the existing `responseGuards`/`assertValidResponse`).
- No new runtime dependency — built on the existing zero-dependency combinator DSL in `src/validation/schema.ts`.
Expand Down
4 changes: 3 additions & 1 deletion api-report/guildpass-sdk.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1147,7 +1147,9 @@ export interface NonceStore {
}

// @public
export const normaliseAddress: (address: string) => string;
export const normaliseAddress: (address: string, options?: {
checksum?: boolean;
}) => string;

// @public
export type OrRule = {
Expand Down
3 changes: 2 additions & 1 deletion docs/THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,8 @@ The cache-adapter boundary is currently partially documented (see `docs/cache-ad

| Scenario | Code Path | Impact |
|----------|-----------|--------|
| `validateAddress` receives a string that passes regex but fails EIP-55 checksum | Checksum check only when `strict: true` | Safe — by design, strict mode is opt-in |
| `validateAddress` receives a mixed-case string that passes regex but fails EIP-55 checksum | Checksum verified automatically whenever the hex payload is mixed case | Safe — a corrupted or tampered checksummed address is now rejected by default, not just under `strict: true` |
| `validateAddress` receives an all-lowercase or all-uppercase string that is not a real address | No checksum check — a uniformly cased address carries no EIP-55 information to verify | Accepted by design; only the format is enforced. Callers that control the casing of their input can opt into `strict: true` to require a checksummed address |
| `validateGuildId` receives a 10MB string | `guildId.trim().length` would compute a very long string, but only `.length` (O(1)) and `.trim()` (O(n)) | **Gap** — no maximum length, allowing very long strings to propagate to downstream functions like `encodeBytes32` |
| `validateResourceId` with a string full of null bytes (`\0`) | Passes all checks (non-empty string) | **Gap** — potential issues in downstream consumers that use this in file paths or SQL queries (though the SDK doesn't do either) |
| `throwValidationError` with sensitive field names | Checks `sensitiveKeys.includes(field.toLowerCase())` → deletes `details.value` | Safe |
Expand Down
54 changes: 54 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,60 @@ On HTTP errors, `GuildPassError.requestMeta` carries the same metadata for corre

---

## Address Utilities

Exported from the root package and from `@guildpass/sdk/utils`.

### `normaliseAddress(address: string, options?: { checksum?: boolean })`

Returns the canonical form of an address.

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `checksum` | `boolean` | `false` | When `true`, returns the EIP-55 checksummed form instead of lowercase. |

By default the address is trimmed and lowercased. Lowercase is the SDK's canonical
internal form — `GuildPassClient` derives its cache keys from it and `areAddressesEqual`
compares through it — so the default must stay lowercase for any value used as a key or
in a comparison. Use `{ checksum: true }` for display output only.

If `checksum: true` is passed a string that is not a well-formed `0x` + 40 hex-digit
address, the trimmed lowercase form is returned rather than a meaningless checksum.
`normaliseAddress` never throws; use `validateAddress` to reject malformed input.

```typescript
normaliseAddress(' 0xD8DA6BF26964AF9D7EED9E03E53415D37AA96045 ');
// '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'

normaliseAddress('0xd8da6bf26964af9d7eed9e03e53415d37aa96045', { checksum: true });
// '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
```

### `validateAddress(address: string, options?: { strict?: boolean })`

Throws `GuildPassConfigError` with `GuildPassErrorCode.INVALID_ADDRESS` if the address is
not a well-formed `0x` + 40 hex-digit string, or `GuildPassErrorCode.INVALID_INPUT` if it
is empty. Returns `void` otherwise.

The EIP-55 checksum is verified automatically when the hex payload is **mixed case**,
because only mixed case carries checksum information. All-lowercase and all-uppercase
addresses carry none and are accepted without a checksum check.

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `strict` | `boolean` | `false` | Forces the checksum check on any casing, including all-lowercase. |

A checksum failure throws `INVALID_ADDRESS` with `details.reason === 'checksum_failed'`.

```typescript
validateAddress('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); // ok — valid checksum
validateAddress('0xD8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); // throws — bad checksum
validateAddress('0xd8da6bf26964af9d7eed9e03e53415d37aa96045'); // ok — no checksum info
validateAddress('0xd8da6bf26964af9d7eed9e03e53415d37aa96045', { strict: true }); // throws
```

---

## Error Handling

Every error the SDK throws is an instance of `GuildPassError`, but you
Expand Down
30 changes: 25 additions & 5 deletions docs/sdk-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,14 @@ await client.roles.getRoles({ guildId: 'guild-1' });

## Address Normalization and Checksums

The SDK automatically normalizes addresses to lowercase for consistency and accepts both lowercase and mixed-case addresses by default.
You can also use the exported utilities to format or strictly validate EIP-55 checksum addresses:
Lowercase is the SDK's canonical internal form: it backs cache keys and every address
comparison, so `normaliseAddress` returns lowercase by default. Pass `{ checksum: true }`
when you want the EIP-55 checksummed form instead — typically for display.

`validateAddress` verifies the EIP-55 checksum automatically when the address is supplied
in mixed case, since mixed case is what carries checksum information. An all-lowercase or
all-uppercase address carries none, so it is accepted unchanged. `{ strict: true }` still
forces the check on any casing.

```typescript
import {
Expand All @@ -226,16 +232,30 @@ import {
validateAddress,
} from '@guildpass/sdk';

// Convert to lowercase
const clean = normaliseAddress('0xabc...');
// Canonical lowercase form (default) — safe for cache keys and comparisons
const clean = normaliseAddress('0xD8DA6BF26964AF9D7EED9E03E53415D37AA96045');
// '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'

// Opt-in EIP-55 checksummed form, for display
const display = normaliseAddress('0xd8da6bf26964af9d7eed9e03e53415d37aa96045', {
checksum: true,
});
// '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'

// Convert to EIP-55 Checksum
const checksummed = toChecksumAddress('0xabc...');

// Check if an address has a valid checksum
const isValid = isChecksumAddress('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); // true

// Strict validation mode (throws if checksum is invalid)
// Mixed case → the checksum is verified automatically
validateAddress('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); // ok
validateAddress('0xD8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); // throws INVALID_ADDRESS

// All-lowercase / all-uppercase carry no checksum information → accepted as before
validateAddress('0xd8da6bf26964af9d7eed9e03e53415d37aa96045'); // ok

// Strict validation mode (forces the check regardless of casing)
validateAddress('0xd8da...', { strict: true });
```

Expand Down
39 changes: 33 additions & 6 deletions src/utils/address.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,44 @@
// GuildPass SDK: Import external module dependencies.
import { keccak256 } from 'js-sha3';

/** A well-formed Ethereum address: `0x` followed by exactly 40 hex digits. */
const ADDRESS_PATTERN = /^0x[a-fA-F0-9]{40}$/;

/**
* Normalises an Ethereum address to lowercase.
* Normalises an Ethereum address.
*
* The default is the lowercased address, and it must stay that way: lowercase is
* the canonical internal form of this SDK. `GuildPassClient` builds its cache keys
* from this function and `areAddressesEqual` compares through it, so emitting
* mixed case by default would split cache entries for one wallet across casings.
*
* Pass `{ checksum: true }` to get the EIP-55 checksummed form for display instead.
*
* @param address The address to normalise
* @returns The lowercased address
* @param options.checksum Return the EIP-55 checksummed form rather than lowercase
* @returns The lowercased address, or its EIP-55 checksummed form when `checksum` is set
*/
// GuildPass SDK: Exported function execution unit.
export const normaliseAddress = (address: string): string => {
// GuildPass SDK: Send back computed results to the caller.
return address.toLowerCase().trim();
// GuildPass SDK: End of logic containment structure block.
export const normaliseAddress = (
address: string,
options: { checksum?: boolean } = {},
): string => {
const trimmed = address.trim();
const lowercased = trimmed.toLowerCase();

if (!options.checksum) {
return lowercased;
}

// `toChecksumAddress` has no notion of a malformed address and would return a
// meaningless mixed-case string for one. Fall back to the canonical form so a
// bad input never comes back looking like a checksummed address. Rejecting it
// is `validateAddress`'s job — this function has never thrown.
if (!ADDRESS_PATTERN.test(trimmed)) {
return lowercased;
}

return toChecksumAddress(trimmed);
};

/**
Expand Down
13 changes: 12 additions & 1 deletion src/utils/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ const throwValidationError = (
/**
* Validates an Ethereum address.
*
* The EIP-55 checksum is verified automatically when the address is supplied in
* mixed case, because mixed case is precisely what carries checksum information.
* An all-lowercase or all-uppercase address encodes none, so it is accepted
* without a checksum check. `strict` forces the check on any casing.
*
* @param address The address to validate
* @param options Validation options to enforce strict mode
* @throws GuildPassError if the address is invalid
Expand All @@ -57,7 +62,13 @@ export const validateAddress = (address: string, options: { strict?: boolean } =
});
}

if (options.strict && !isChecksumAddress(address)) {
// Only a mixed-case payload carries EIP-55 checksum information. A uniformly
// cased address carries none, so checking it would reject perfectly legitimate
// lowercase input — that stays opt-in behind `strict`.
const hex = address.slice(2);
const hasChecksumInformation = /[a-f]/.test(hex) && /[A-F]/.test(hex);

if ((hasChecksumInformation || options.strict) && !isChecksumAddress(address)) {
throwValidationError(`Address fails EIP-55 checksum: ${address}`, GuildPassErrorCode.INVALID_ADDRESS, {
field: 'address',
reason: 'checksum_failed',
Expand Down
5 changes: 4 additions & 1 deletion tests/access.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import checkAccessSuccess from './fixtures/access/check-access-success.json';
import checkRoleAccessSuccess from './fixtures/access/check-role-access-success.json';

const validAddress = '0x1234567890123456789012345678901234567890';
const mixedCaseAddress = '0xAbCdEf1234567890AbCdEf1234567890AbCdEf12';
// Mixed case on purpose, to prove the service lowercases it before building a
// request. The value must carry a valid EIP-55 checksum, since validateAddress
// verifies the checksum of any mixed-case address it is given.
const mixedCaseAddress = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266';

function createService(response: unknown) {
const get = vi.fn().mockResolvedValue(response);
Expand Down
5 changes: 4 additions & 1 deletion tests/serviceUrlEncoding.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { describe, expect, it, vi } from 'vitest';
import { GuildPassClient } from '../src/client/GuildPassClient';

const mixedCaseAddress = '0xAbCdEf1234567890AbCdEf1234567890AbCdEf12';
// Mixed case on purpose, to prove the services lowercase it before building a
// URL. The value must carry a valid EIP-55 checksum, since validateAddress
// verifies the checksum of any mixed-case address it is given.
const mixedCaseAddress = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266';
const normalisedAddress = mixedCaseAddress.toLowerCase();

function createClient(responseBody: unknown = {}) {
Expand Down
6 changes: 3 additions & 3 deletions tests/services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ describe('Service Modules', () => {
headers: new Headers(),
});

const mixedCaseAddress = '0xAbCdEf1234567890AbCdEf1234567890AbCdEf12';
const mixedCaseAddress = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266';
await client.access.checkAccess({
walletAddress: mixedCaseAddress,
guildId: 'guild_1',
Expand Down Expand Up @@ -359,7 +359,7 @@ describe('Service Modules', () => {
headers: new Headers(),
});

const mixedCaseAddress = '0xAbCdEf1234567890AbCdEf1234567890AbCdEf12';
const mixedCaseAddress = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266';
await client.roles.getUserRoles({
guildId: 'guild_1',
walletAddress: mixedCaseAddress,
Expand Down Expand Up @@ -488,7 +488,7 @@ describe('Service Modules', () => {
headers: new Headers(),
});

const mixedCase = '0xAbCdEf1234567890AbCdEf1234567890AbCdEf12';
const mixedCase = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266';
await client.roles.hasRole({
walletAddress: mixedCase,
guildId: 'guild_1',
Expand Down
Loading