diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ed2093..dbdae6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/api-report/guildpass-sdk.api.md b/api-report/guildpass-sdk.api.md index c96a91c..64844d9 100644 --- a/api-report/guildpass-sdk.api.md +++ b/api-report/guildpass-sdk.api.md @@ -346,6 +346,15 @@ export interface EIP1193Provider { }): Promise; } +// @public +export const EIP1271_MAGIC_VALUE = "0x1626ba7e"; + +// @public +export interface Eip1271Outcome { + reason?: string; + valid: boolean; +} + // @public export interface EIP712Domain { // (undocumented) @@ -1430,6 +1439,11 @@ export interface SiweParseResult { success: boolean; } +// @public +export interface SiweVerifyAsyncParams extends SiweVerifyParams { + contractProvider?: ContractProvider; +} + // @public export interface SiweVerifyParams { checkExpiry?: boolean; @@ -1599,7 +1613,10 @@ export function verifyGuildRoleDelegationWithReplayProtection(domain: EIP712Doma export function verifySiweSignature(params: SiweVerifyParams): SiweVerifyResult; // @public -export function verifySiweSignatureWithReplayProtection(params: SiweVerifyParams, nonceStore: NonceStore): Promise; +export function verifySiweSignatureAsync(params: SiweVerifyAsyncParams): Promise; + +// @public +export function verifySiweSignatureWithReplayProtection(params: SiweVerifyAsyncParams, nonceStore: NonceStore): Promise; // @public export function verifyTypedDataSignature(domain: EIP712Domain, types: EIP712Types, primaryType: string, message: EIP712Message, signature: string, expectedSigner: string): EIP712VerifyResult; diff --git a/docs/api-reference.md b/docs/api-reference.md index cc10f5d..4ef302f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -835,6 +835,58 @@ function publishRoot(root: string, version?: number): Promise function rotateWhitelist(newRoot: string, version?: number): Promise ``` +## 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` + +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` / diff --git a/docs/sdk-guide.md b/docs/sdk-guide.md index 75e686d..a66f0e9 100644 --- a/docs/sdk-guide.md +++ b/docs/sdk-guide.md @@ -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 { diff --git a/src/siwe/eip1271.ts b/src/siwe/eip1271.ts new file mode 100644 index 0000000..969a49b --- /dev/null +++ b/src/siwe/eip1271.ts @@ -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 { + 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 }; +} diff --git a/src/siwe/index.ts b/src/siwe/index.ts index b19e6bf..14c9d81 100644 --- a/src/siwe/index.ts +++ b/src/siwe/index.ts @@ -6,6 +6,7 @@ export type { SiweMessage, SiweVerifyParams, + SiweVerifyAsyncParams, SiweVerifyResult, SiweParseResult, } from './siwe.types'; @@ -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'; diff --git a/src/siwe/replayProtection.ts b/src/siwe/replayProtection.ts index ee62609..ed52403 100644 --- a/src/siwe/replayProtection.ts +++ b/src/siwe/replayProtection.ts @@ -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'; /** @@ -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 { // 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; } diff --git a/src/siwe/siwe.helpers.ts b/src/siwe/siwe.helpers.ts index 7ad1942..24d0939 100644 --- a/src/siwe/siwe.helpers.ts +++ b/src/siwe/siwe.helpers.ts @@ -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'; @@ -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 { + 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, + }; +} diff --git a/src/siwe/siwe.types.ts b/src/siwe/siwe.types.ts index a88c242..8927d74 100644 --- a/src/siwe/siwe.types.ts +++ b/src/siwe/siwe.types.ts @@ -1,3 +1,6 @@ +// GuildPass SDK: Pull in package or module bindings. +import type { ContractProvider } from '../contracts/providers/provider.types'; + /** * Represents a parsed EIP-4361 (Sign-In With Ethereum) message. * @@ -77,6 +80,21 @@ export interface SiweVerifyResult { code?: string; } +/** + * Parameters accepted by the asynchronous verifiers, which can additionally + * fall back to EIP-1271 verification for smart-contract wallets. + */ +export interface SiweVerifyAsyncParams extends SiweVerifyParams { + /** + * Provider used to `eth_call` the claimed address's `isValidSignature`. + * + * Supplying this makes verification perform **network I/O**, unlike the + * purely local {@link verifySiweSignature}. Omit it and the asynchronous + * verifier behaves exactly like the synchronous one. + */ + contractProvider?: ContractProvider; +} + /** * Result returned by {@link parseSiweMessage}. */ diff --git a/tests/siwe-eip1271.test.ts b/tests/siwe-eip1271.test.ts new file mode 100644 index 0000000..9a4b4f5 --- /dev/null +++ b/tests/siwe-eip1271.test.ts @@ -0,0 +1,324 @@ +/** + * EIP-1271 smart-contract wallet verification for SIWE (#213). + * + * The fallback is deliberately keyed on *any* SIWE_INVALID_SIGNATURE outcome + * rather than on a recovered-address mismatch: an EIP-1271 signature has no + * fixed length, so a Safe signature is rejected by the 65-byte guard before + * ECDSA recovery ever runs. Several tests below exist specifically to pin that. + */ +import { describe, it, expect, vi } from 'vitest'; +import { + verifySiweSignature, + verifySiweSignatureAsync, + verifySiweSignatureWithReplayProtection, + InMemoryNonceStore, + EIP1271_MAGIC_VALUE, +} from '../src/siwe'; +import { encodeIsValidSignature } from '../src/siwe/eip1271'; +import { GuildPassErrorCode } from '../src/errors/errorCodes'; +import { hashPersonalMessage } from '../src/crypto/secp256k1'; +import type { ContractProvider } from '../src/contracts/providers/provider.types'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +/** The address claimed by the message below; treated as a contract wallet here. */ +const WALLET = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; + +const MESSAGE = + 'example.com wants you to sign in with your Ethereum account:\n' + + '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266\n' + + '\n' + + 'URI: https://example.com\n' + + 'Version: 1\n' + + 'Chain ID: 1\n' + + 'Nonce: abc12345\n' + + 'Issued At: 2024-01-01T00:00:00.000Z'; + +/** A genuine ECDSA signature over MESSAGE by WALLET's key — verifies locally. */ +const VALID_EOA_SIGNATURE = + '0x82790bc51f261e6461cb1a3baeed8494cd796093c93db2b564c2260535203c612ca06a4cf8ca39e15452d8fbd24000c6d752a45c5c46ae1ced3c641b5370c1901b'; + +/** + * A 200-byte signature — the shape a Safe with multiple owners produces. + * Nothing about it is recoverable; the point is that it is not 65 bytes. + */ +const CONTRACT_SIGNATURE = `0x${'ab'.repeat(200)}`; + +/** A well-formed 65-byte signature that simply recovers to the wrong address. */ +const MISMATCHED_SIGNATURE = `0x${'11'.repeat(32)}${'22'.repeat(32)}1b`; + +const MAGIC_WORD = `${EIP1271_MAGIC_VALUE}${'0'.repeat(56)}`; + +/** Minimal ContractProvider stub; only `ethCall` is exercised. */ +function mockProvider(impl: () => Promise): ContractProvider { + return { + ethCall: vi.fn(impl), + batchEthCall: vi.fn(), + } as unknown as ContractProvider; +} + +const acceptingProvider = () => mockProvider(async () => MAGIC_WORD); + +// --------------------------------------------------------------------------- + +describe('verifySiweSignatureAsync — EIP-1271 fallback', () => { + it('accepts a contract signature when the wallet returns the magic value', async () => { + const provider = acceptingProvider(); + + const result = await verifySiweSignatureAsync({ + message: MESSAGE, + signature: CONTRACT_SIGNATURE, + contractProvider: provider, + }); + + expect(result.success).toBe(true); + expect(result.data?.address).toBe(WALLET); + expect(result.error).toBeUndefined(); + }); + + it('reaches the fallback for a signature that is not 65 bytes', async () => { + // The load-bearing case. `verifySiweSignature` rejects CONTRACT_SIGNATURE at + // the length guard, long before ECDSA recovery — so an implementation that + // only falls back on a recovered-address mismatch never calls the provider + // at all, and no real smart-contract wallet would ever verify. + const provider = acceptingProvider(); + + const sync = verifySiweSignature({ message: MESSAGE, signature: CONTRACT_SIGNATURE }); + expect(sync.success).toBe(false); + expect(sync.error).toMatch(/65 bytes/); + + await verifySiweSignatureAsync({ + message: MESSAGE, + signature: CONTRACT_SIGNATURE, + contractProvider: provider, + }); + + expect(provider.ethCall).toHaveBeenCalledTimes(1); + }); + + it('also reaches the fallback when a 65-byte signature recovers to another address', async () => { + const provider = acceptingProvider(); + + const result = await verifySiweSignatureAsync({ + message: MESSAGE, + signature: MISMATCHED_SIGNATURE, + contractProvider: provider, + }); + + expect(provider.ethCall).toHaveBeenCalledTimes(1); + expect(result.success).toBe(true); + }); + + it('rejects when the contract returns a different word', async () => { + const provider = mockProvider(async () => `0x${'0'.repeat(64)}`); + + const result = await verifySiweSignatureAsync({ + message: MESSAGE, + signature: CONTRACT_SIGNATURE, + contractProvider: provider, + }); + + expect(result.success).toBe(false); + expect(result.code).toBe(GuildPassErrorCode.SIWE_INVALID_SIGNATURE); + }); + + it('rejects a result that merely starts with the magic selector', async () => { + // `bytes4` is right-padded, so anything after the selector must be zeroes. + // A `startsWith` check would wrongly accept this. + const provider = mockProvider(async () => `${EIP1271_MAGIC_VALUE}${'f'.repeat(56)}`); + + const result = await verifySiweSignatureAsync({ + message: MESSAGE, + signature: CONTRACT_SIGNATURE, + contractProvider: provider, + }); + + expect(result.success).toBe(false); + expect(result.code).toBe(GuildPassErrorCode.SIWE_INVALID_SIGNATURE); + }); + + it('resolves rather than rejecting when the RPC call fails', async () => { + const provider = mockProvider(async () => { + throw new Error('connection refused'); + }); + + // The whole contract of SiweVerifyResult is that verification never throws. + await expect( + verifySiweSignatureAsync({ + message: MESSAGE, + signature: CONTRACT_SIGNATURE, + contractProvider: provider, + }), + ).resolves.toMatchObject({ + success: false, + code: GuildPassErrorCode.SIWE_INVALID_SIGNATURE, + }); + }); + + it('rejects a non-string eth_call result', async () => { + // `ContractProvider.ethCall` is typed as `Promise`. + const provider = mockProvider(async () => ({ unexpected: true })); + + const result = await verifySiweSignatureAsync({ + message: MESSAGE, + signature: CONTRACT_SIGNATURE, + contractProvider: provider, + }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/non-string/); + }); + + it('behaves exactly like the synchronous verifier without a contractProvider', async () => { + const sync = verifySiweSignature({ message: MESSAGE, signature: CONTRACT_SIGNATURE }); + const async = await verifySiweSignatureAsync({ + message: MESSAGE, + signature: CONTRACT_SIGNATURE, + }); + + expect(async).toEqual(sync); + }); + + it('never touches the network for a valid EOA signature', async () => { + const provider = acceptingProvider(); + + const result = await verifySiweSignatureAsync({ + message: MESSAGE, + signature: VALID_EOA_SIGNATURE, + contractProvider: provider, + }); + + expect(result.success).toBe(true); + expect(provider.ethCall).not.toHaveBeenCalled(); + }); + + it('does not fall back for non-signature failures', async () => { + const provider = acceptingProvider(); + + // A domain mismatch is terminal: no contract signature can fix a message + // that was addressed to somewhere else. + const result = await verifySiweSignatureAsync({ + message: MESSAGE, + signature: CONTRACT_SIGNATURE, + expectedDomain: 'evil.com', + contractProvider: provider, + }); + + expect(result.success).toBe(false); + expect(result.code).toBe(GuildPassErrorCode.SIWE_DOMAIN_MISMATCH); + expect(provider.ethCall).not.toHaveBeenCalled(); + }); + + it('does not fall back for a nonce mismatch', async () => { + const provider = acceptingProvider(); + + const result = await verifySiweSignatureAsync({ + message: MESSAGE, + signature: CONTRACT_SIGNATURE, + expectedNonce: 'not-the-nonce', + contractProvider: provider, + }); + + expect(result.success).toBe(false); + expect(result.code).toBe(GuildPassErrorCode.SIWE_INVALID_MESSAGE); + expect(provider.ethCall).not.toHaveBeenCalled(); + }); +}); + +describe('EIP-1271 call encoding', () => { + it('encodes isValidSignature(bytes32,bytes) with a correct dynamic tail', async () => { + const provider = acceptingProvider(); + + await verifySiweSignatureAsync({ + message: MESSAGE, + signature: CONTRACT_SIGNATURE, + contractProvider: provider, + }); + + const [request] = (provider.ethCall as ReturnType).mock.calls[0]; + expect(request.to).toBe(WALLET); + + const data: string = request.data; + const body = data.slice(2 + 8); // strip 0x and the 4-byte selector + + expect(data.startsWith(EIP1271_MAGIC_VALUE)).toBe(true); + + // Word 0: the EIP-191 digest the ECDSA path would have used. + const digest = Buffer.from(hashPersonalMessage(MESSAGE)).toString('hex'); + expect(body.slice(0, 64)).toBe(digest); + + // Word 1: offset to the dynamic tail — two words in. + expect(BigInt(`0x${body.slice(64, 128)}`)).toBe(64n); + + // Word 2: byte length of the signature. + expect(BigInt(`0x${body.slice(128, 192)}`)).toBe(200n); + + // Tail: the signature body, right-padded to a whole number of words. + const tail = body.slice(192); + expect(tail.startsWith('ab'.repeat(200))).toBe(true); + expect(tail.length % 64).toBe(0); + }); + + it('pads a signature whose length is not a multiple of 32 bytes', () => { + const digest = hashPersonalMessage(MESSAGE); + const encoded = encodeIsValidSignature(digest, `0x${'cd'.repeat(65)}`); + const tail = encoded.slice(2 + 8 + 64 + 64 + 64); + + expect(BigInt(`0x${encoded.slice(2 + 8 + 64 + 64, 2 + 8 + 64 + 64 + 64)}`)).toBe(65n); + // 65 bytes is 130 hex chars, which rounds up to three 32-byte words (192). + expect(tail.length).toBe(192); + expect(tail.startsWith('cd'.repeat(65))).toBe(true); + expect(tail.slice(130)).toBe('0'.repeat(62)); + }); +}); + +describe('EIP-1271 composes with replay protection', () => { + it('accepts a contract signature once and rejects the replay', async () => { + const provider = acceptingProvider(); + const nonceStore = new InMemoryNonceStore(); + + const params = { + message: MESSAGE, + signature: CONTRACT_SIGNATURE, + contractProvider: provider, + }; + + const first = await verifySiweSignatureWithReplayProtection(params, nonceStore); + expect(first.success).toBe(true); + + const second = await verifySiweSignatureWithReplayProtection(params, nonceStore); + expect(second.success).toBe(false); + expect(second.code).toBe(GuildPassErrorCode.SIWE_REPLAY_DETECTED); + }); + + it('never consumes a nonce when the contract rejects the signature', async () => { + const rejecting = mockProvider(async () => `0x${'0'.repeat(64)}`); + const nonceStore = new InMemoryNonceStore(); + + const failed = await verifySiweSignatureWithReplayProtection( + { message: MESSAGE, signature: CONTRACT_SIGNATURE, contractProvider: rejecting }, + nonceStore, + ); + expect(failed.success).toBe(false); + + // The nonce must still be available to a subsequent legitimate sign-in. + const accepted = await verifySiweSignatureWithReplayProtection( + { message: MESSAGE, signature: CONTRACT_SIGNATURE, contractProvider: acceptingProvider() }, + nonceStore, + ); + expect(accepted.success).toBe(true); + }); + + it('still works for EOA signatures with no contractProvider', async () => { + const nonceStore = new InMemoryNonceStore(); + + const result = await verifySiweSignatureWithReplayProtection( + { message: MESSAGE, signature: VALID_EOA_SIGNATURE }, + nonceStore, + ); + + expect(result.success).toBe(true); + }); +});