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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased]

### Added
- **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.
- Verification requires the returned `bytes4` magic value to fill its whole 32-byte word (`0x1626ba7e` plus 28 zero bytes, exported as `EIP1271_MAGIC_VALUE`); a result that merely starts with the selector is rejected.
- RPC failures, non-contract addresses and reverting `isValidSignature` calls all resolve to `{ success: false, code: 'SIWE_INVALID_SIGNATURE' }` — the never-throws contract of `SiweVerifyResult` is preserved.
- `verifySiweSignatureWithReplayProtection` now accepts the same `SiweVerifyAsyncParams`, so EIP-1271 verification and replay protection compose. This widens the accepted parameter type and is backward compatible.
- **`verifySiweSignature` is unchanged**, along with all of its tests: the synchronous EOA path performs no network I/O and behaves exactly as before. Omitting `contractProvider` makes the async variant identical to it.
- **`InMemoryCacheAdapter` supports optional `maxEntries` LRU eviction** — resolves [#386](https://github.com/Adamantine-Guild/guildpass-sdk/issues/386). `new InMemoryCacheAdapter({ maxEntries: 5_000 })` caps the cache; once full, the least-recently-used entry is evicted on the next write. This closes a long-standing contradiction: [`docs/cache-adapters.md`](docs/cache-adapters.md) already documented a `ttl: undefined` entry as stored *"until explicitly deleted or evicted by LRU"*, but the adapter had no eviction of any kind.
- Recency is refreshed by `get` as well as `set`, so a frequently-read key survives even when it was inserted first. Both paths re-insert rather than overwrite: `Map.set` on an existing key keeps its original insertion position, which would have made the eviction order FIFO while still passing the simple cases.
- Within `get`, the TTL sweep runs before the recency refresh, so an expired entry is dropped rather than promoted to most-recently-used.
Expand Down
19 changes: 18 additions & 1 deletion api-report/guildpass-sdk.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,15 @@ export interface EIP1193Provider {
}): Promise<any>;
}

// @public
export const EIP1271_MAGIC_VALUE = "0x1626ba7e";

// @public
export interface Eip1271Outcome {
reason?: string;
valid: boolean;
}

// @public
export interface EIP712Domain {
// (undocumented)
Expand Down Expand Up @@ -1430,6 +1439,11 @@ export interface SiweParseResult {
success: boolean;
}

// @public
export interface SiweVerifyAsyncParams extends SiweVerifyParams {
contractProvider?: ContractProvider;
}

// @public
export interface SiweVerifyParams {
checkExpiry?: boolean;
Expand Down Expand Up @@ -1599,7 +1613,10 @@ export function verifyGuildRoleDelegationWithReplayProtection(domain: EIP712Doma
export function verifySiweSignature(params: SiweVerifyParams): SiweVerifyResult;

// @public
export function verifySiweSignatureWithReplayProtection(params: SiweVerifyParams, nonceStore: NonceStore): Promise<SiweVerifyResult>;
export function verifySiweSignatureAsync(params: SiweVerifyAsyncParams): Promise<SiweVerifyResult>;

// @public
export function verifySiweSignatureWithReplayProtection(params: SiweVerifyAsyncParams, nonceStore: NonceStore): Promise<SiweVerifyResult>;

// @public
export function verifyTypedDataSignature(domain: EIP712Domain, types: EIP712Types, primaryType: string, message: EIP712Message, signature: string, expectedSigner: string): EIP712VerifyResult;
Expand Down
52 changes: 52 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,58 @@ function publishRoot(root: string, version?: number): Promise<void>
function rotateWhitelist(newRoot: string, version?: number): Promise<void>
```

## SIWE (Sign-In With Ethereum)

### `verifySiweSignature(params: SiweVerifyParams): SiweVerifyResult`

Synchronous EIP-4361 verification. Parses the message, checks domain, nonce,
expiry and `notBefore`, then recovers the signer with secp256k1 and compares it
against the address in the message. Purely local — **no network access**. Never
throws: every failure comes back as `{ success: false, error, code }`.

### `verifySiweSignatureAsync(params: SiweVerifyAsyncParams): Promise<SiweVerifyResult>`

Same checks, plus an **EIP-1271 fallback for smart-contract wallets** (Safe,
Argent, and account-abstraction wallets generally), which have no single ECDSA
keypair to recover from and instead answer `isValidSignature(bytes32,bytes)`
on-chain.

```typescript
import { verifySiweSignatureAsync } from '@guildpass/sdk';

const result = await verifySiweSignatureAsync({
message: rawSiweMessage,
signature,
expectedDomain: 'example.com',
contractProvider, // any ContractProvider — this is what enables the fallback
});
```

- **Requires RPC access**, unlike the synchronous function: when the fallback
runs it performs an `eth_call` against the address claimed by the message.
Omit `contractProvider` and the behaviour is identical to
`verifySiweSignature`, with no request made.
- **The fallback only runs after a signature failure** (`SIWE_INVALID_SIGNATURE`).
A domain, nonce, expiry or `notBefore` failure is terminal and returned
unchanged — a contract signature cannot rescue a message addressed elsewhere.
- **It covers every signature failure, not just an address mismatch.** 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 even attempted.
- **A valid EOA signature never touches the network** — the synchronous path
succeeds first.
- **RPC failures do not reject.** A refused connection, a non-contract address
or a reverting `isValidSignature` all resolve to
`{ success: false, code: 'SIWE_INVALID_SIGNATURE' }`, preserving the
never-throws contract.
- Verification succeeds only when the contract returns the EIP-1271 magic value
as a full 32-byte word (`0x1626ba7e` followed by 28 zero bytes), exported as
`EIP1271_MAGIC_VALUE`. A result that merely starts with the selector is
rejected.

`verifySiweSignatureWithReplayProtection` accepts the same
`SiweVerifyAsyncParams`, so EIP-1271 verification and replay protection compose:
pass `contractProvider` and a smart-contract wallet gets both.

## EIP-712 (Typed-Data Signing)

Generic `eth_signTypedData_v4` support — `encodeType` / `hashStruct` /
Expand Down
9 changes: 9 additions & 0 deletions docs/sdk-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,15 @@ const result = await verifySiweSignatureWithReplayProtection(
nonceStore,
);

// Smart-contract wallets: add a contractProvider and the same call additionally
// falls back to EIP-1271 verification, so replay protection and contract-wallet
// support compose instead of being mutually exclusive.
//
// await verifySiweSignatureWithReplayProtection(
// { message: rawSiweMessage, signature, contractProvider },
// nonceStore,
// );

if (result.success) {
// First time through: verified and the nonce is now consumed.
} else {
Expand Down
124 changes: 124 additions & 0 deletions src/siwe/eip1271.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* EIP-1271 signature verification for smart-contract wallets.
*
* Smart-contract wallets (Safe, Argent, and account-abstraction wallets
* generally) have no single ECDSA keypair to recover from. They implement
* `isValidSignature(bytes32,bytes)` on-chain instead, so verifying one of their
* signatures means asking the contract itself rather than doing local crypto.
*
* @module siwe/eip1271
*/

// GuildPass SDK: Pull in package or module bindings.
import type { ContractProvider } from '../contracts/providers/provider.types';

/**
* The EIP-1271 `isValidSignature(bytes32,bytes)` selector, which is also the
* magic value a contract must return to declare a signature valid.
*/
export const EIP1271_MAGIC_VALUE = '0x1626ba7e';

/**
* The magic value as it actually comes back from `eth_call`.
*
* `isValidSignature` returns `bytes4`, and ABI encoding left-aligns a
* fixed-size byte array inside its 32-byte word — so the selector is followed
* by 28 zero bytes. Comparing against this whole word, rather than testing
* whether the result merely *starts with* the selector, is what stops a
* contract that returns `0x1626ba7e` plus arbitrary trailing data from being
* accepted.
*/
const EIP1271_MAGIC_WORD = `${EIP1271_MAGIC_VALUE}${'0'.repeat(56)}`;

/** Hex characters in one 32-byte ABI word. */
const HEX_CHARS_PER_WORD = 64;

/** Hex-encodes a byte array. The SDK ships no shared helper for this. */
function toHex(bytes: Uint8Array): string {
let out = '';
for (const byte of bytes) out += byte.toString(16).padStart(2, '0');
return out;
}

/** Right-pads a dynamic `bytes` body to a whole number of 32-byte words. */
function padTail(hex: string): string {
const remainder = hex.length % HEX_CHARS_PER_WORD;
return remainder === 0 ? hex : hex + '0'.repeat(HEX_CHARS_PER_WORD - remainder);
}

/**
* ABI-encodes a call to `isValidSignature(bytes32 hash, bytes signature)`.
*
* Hand-rolled deliberately: the shared `encodeAbiParams` helper supports static
* types only and throws for anything dynamic, while `bytes` needs head/tail
* encoding — an offset word pointing past the head, then a length word, then
* the body right-padded to a word boundary.
*/
export function encodeIsValidSignature(digest: Uint8Array, signature: string): string {
const sigHex = (signature.startsWith('0x') ? signature.slice(2) : signature).toLowerCase();

// Head is two words (the hash and this offset), so the tail starts at byte 64.
const offsetWord = (64).toString(16).padStart(HEX_CHARS_PER_WORD, '0');
const lengthWord = (sigHex.length / 2).toString(16).padStart(HEX_CHARS_PER_WORD, '0');

return `${EIP1271_MAGIC_VALUE}${toHex(digest)}${offsetWord}${lengthWord}${padTail(sigHex)}`;
}

/**
* Result of an EIP-1271 check.
*
* Never carries an exception: a transport failure is reported as `valid: false`
* with a `reason`, because the caller's contract is to return a result rather
* than reject.
*/
export interface Eip1271Outcome {
/** Whether the contract returned the EIP-1271 magic value. */
valid: boolean;
/** Why the check did not pass. Undefined when `valid` is true. */
reason?: string;
}

/**
* Asks the contract at `address` whether `signature` is valid for `digest`.
*
* @param provider Used to `eth_call` the contract. This is network I/O.
* @param address The claimed signer — must be the contract wallet itself.
* @param digest The 32-byte hash that was signed (for SIWE, the EIP-191 digest).
* @param signature The wallet's signature, of any length.
*/
export async function checkEip1271Signature(
provider: ContractProvider,
address: string,
digest: Uint8Array,
signature: string,
): Promise<Eip1271Outcome> {
let raw: unknown;

try {
raw = await provider.ethCall({
to: address,
data: encodeIsValidSignature(digest, signature),
});
} catch (err) {
// An address that is not a contract, an RPC outage, and a reverting
// `isValidSignature` all land here. None of them prove the signature valid,
// and none of them should escape as a rejection.
return {
valid: false,
reason: `EIP-1271 verification call failed: ${
err instanceof Error ? err.message : 'unknown error'
}`,
};
}

// `ContractProvider.ethCall` resolves to `unknown`; narrow before comparing.
if (typeof raw !== 'string') {
return { valid: false, reason: 'EIP-1271 call returned a non-string result' };
}

if (raw.toLowerCase() !== EIP1271_MAGIC_WORD) {
return { valid: false, reason: 'EIP-1271 contract did not return the magic value' };
}

return { valid: true };
}
5 changes: 5 additions & 0 deletions src/siwe/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
export type {
SiweMessage,
SiweVerifyParams,
SiweVerifyAsyncParams,
SiweVerifyResult,
SiweParseResult,
} from './siwe.types';
Expand All @@ -14,10 +15,14 @@ export {
formatSiweMessage,
parseSiweMessage,
verifySiweSignature,
verifySiweSignatureAsync,
generateSiweNonce,
MAX_SIWE_MESSAGE_LENGTH,
} from './siwe.helpers';

export { EIP1271_MAGIC_VALUE } from './eip1271';
export type { Eip1271Outcome } from './eip1271';

export { InMemoryNonceStore } from './nonceStore';
export type { NonceStore } from './nonceStore';

Expand Down
11 changes: 7 additions & 4 deletions src/siwe/replayProtection.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// GuildPass SDK: Pull in package or module bindings.
import { GuildPassErrorCode } from '../errors/errorCodes';
import { verifySiweSignature, parseSiweMessage } from './siwe.helpers';
import { SiweVerifyParams, SiweVerifyResult } from './siwe.types';
import { verifySiweSignatureAsync, parseSiweMessage } from './siwe.helpers';
import { SiweVerifyAsyncParams, SiweVerifyResult } from './siwe.types';
import { NonceStore } from './nonceStore';

/**
Expand All @@ -25,11 +25,14 @@ import { NonceStore } from './nonceStore';
* `false`, `code` is `SIWE_REPLAY_DETECTED`, and `error` explains it.
*/
export async function verifySiweSignatureWithReplayProtection(
params: SiweVerifyParams,
params: SiweVerifyAsyncParams,
nonceStore: NonceStore,
): Promise<SiweVerifyResult> {
// 1. Full signature + EIP-4361 verification first. Never consume on failure.
const result = verifySiweSignature(params);
// Routed through the async verifier so a smart-contract wallet composes
// with replay protection instead of having to choose between the two.
// Without a `contractProvider` this resolves to the synchronous result.
const result = await verifySiweSignatureAsync(params);
if (!result.success || !result.data) {
return result;
}
Expand Down
67 changes: 67 additions & 0 deletions src/siwe/siwe.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import { GuildPassErrorCode } from '../errors/errorCodes';
import type {
SiweMessage,
SiweParseResult,
SiweVerifyAsyncParams,
SiweVerifyParams,
SiweVerifyResult,
} from './siwe.types';
import { checkEip1271Signature } from './eip1271';
import { constantTimeEqual } from '../utils';
import { isNodeEnvironment, hasWebCrypto } from '../utils/env';

Expand Down Expand Up @@ -455,3 +457,68 @@ export function generateSiweNonce(): string {
}
return result;
}

/**
* EIP-4361 verification with an EIP-1271 fallback for smart-contract wallets.
*
* Delegates to {@link verifySiweSignature} first and returns its result
* unchanged for every non-signature failure — domain, nonce, expiry,
* `notBefore`. A contract signature cannot rescue a message addressed to the
* wrong domain, so those outcomes are terminal.
*
* The fallback fires on **any** `SIWE_INVALID_SIGNATURE` outcome, not only on a
* recovered-address mismatch. That distinction is load-bearing: an EIP-1271
* signature has no fixed length, so a Safe signature is rejected by the 65-byte
* guard well before ECDSA recovery ever runs. Keying the fallback on the
* mismatch alone would never reach a real smart-contract wallet.
*
* Unlike {@link verifySiweSignature}, this performs **network I/O** when
* `contractProvider` is supplied. Without it, the behaviour is identical to the
* synchronous verifier and no request is made.
*
* @example
* ```typescript
* const result = await verifySiweSignatureAsync({
* message: rawMessage,
* signature: '0xabc...def',
* expectedDomain: 'example.com',
* contractProvider: client.contracts.provider,
* });
* ```
*/
export async function verifySiweSignatureAsync(
params: SiweVerifyAsyncParams,
): Promise<SiweVerifyResult> {
const result = verifySiweSignature(params);

if (result.success) return result;
if (result.code !== GuildPassErrorCode.SIWE_INVALID_SIGNATURE) return result;

const { contractProvider, message, signature } = params;
if (!contractProvider) return result;
// The synchronous path rejects non-string inputs with this same code, so
// re-check rather than trusting the code alone.
if (typeof message !== 'string' || typeof signature !== 'string') return result;

// The synchronous verifier does not hand back the parsed message on failure,
// so re-parse to learn which address is claiming the signature.
const parsed = parseSiweMessage(message);
if (!parsed.success || !parsed.data) return result;

const outcome = await checkEip1271Signature(
contractProvider,
parsed.data.address,
// The same EIP-191 digest the ECDSA path used: a contract wallet signs the
// personal-message hash, not the raw string.
hashPersonalMessage(message),
signature,
);

if (outcome.valid) return { success: true, data: parsed.data };

return {
success: false,
error: outcome.reason ?? 'Signature is not valid for this address (ECDSA or EIP-1271)',
code: GuildPassErrorCode.SIWE_INVALID_SIGNATURE,
};
}
Loading