diff --git a/examples/circle-smart-account/.env.example b/examples/circle-smart-account/.env.example index 115a601..19f3c88 100644 --- a/examples/circle-smart-account/.env.example +++ b/examples/circle-smart-account/.env.example @@ -1,2 +1,6 @@ +# Circle Modular Wallets — from https://console.circle.com/wallets/modular/configurator +# Also set Configurator → Passkey → Domain Name to `localhost` (required for Register/Login). +# Base Client URL only (do not include /polygonAmoy; the app appends the chain). +# Example: https://modular-sdk.circle.com/v1/rpc/w3s/buidl VITE_CLIENT_KEY= VITE_CLIENT_URL= diff --git a/examples/circle-smart-account/README.md b/examples/circle-smart-account/README.md index b065561..2c292eb 100644 --- a/examples/circle-smart-account/README.md +++ b/examples/circle-smart-account/README.md @@ -2,66 +2,106 @@ This example Vite application demonstrates how to register and log in to a Circle Smart Account using passkeys. It also showcases how to perform a user operation with the account on Polygon Amoy. -## Run the example app +## Prerequisites -Please follow the instructions to run the example app on your local machine. +- A [Circle Console](https://console.circle.com/) account with Modular Wallets enabled +- See Circle’s [console setup guide](https://developers.circle.com/wallets/modular/console-setup) for the full Client Key + Passkey flow -### Environment Variables +## Environment variables -Before you start to run the app, you need to make sure that all environment variables are configured correctly. +Copy `.env.example` to `.env` in this folder (`examples/circle-smart-account/.env`): -Make a copy of `.env.example` and rename it to `.env`. +```bash +cp .env.example .env +``` -Under `.env`, make sure the following environment variables are configured properly: +| Variable | Description | +| --- | --- | +| `VITE_CLIENT_KEY` | Client Key from the Circle Developer Console | +| `VITE_CLIENT_URL` | Base Client URL from the Circle Developer Console — **without** the chain suffix | -- `VITE_CLIENT_KEY`: Paste your Client Key here. You can create one in [Circle Developer Console](https://console.circle.com/wallets/modular/configurator). -- `VITE_CLIENT_URL`: Paste the Client URL here. You can copy it from [Circle Developer Console](https://console.circle.com/wallets/modular/configurator). +> [!IMPORTANT] +> `VITE_CLIENT_URL` must be a full `https://...` URL. The app appends `/polygonAmoy` internally for the modular transport. Passkey (RP) calls use the base URL only. If this variable is empty or already includes `/polygonAmoy`, setup will fail (for example `InvalidProviderError` for `/polygonAmoy`). -Once you have these environment variables setup, you can now follow the steps below to run the app locally. +Example `.env` shape (use your own credentials): -### Install dependencies +```env +VITE_CLIENT_KEY=your-client-key +VITE_CLIENT_URL=https://modular-sdk.circle.com/v1/rpc/w3s/buidl +``` -You first need to make sure you have followed the [README](https://github.com/circlefin/modularwallets-web-sdk/blob/master/README.md) under project root and have installed all dependencies under root folder: +Restart `pnpm dev` after changing `.env`. -```bash -$ pnpm install -``` +## Circle console setup -#### Important: Build the SDK +Do **both** of the following in [Configurator](https://console.circle.com/wallets/modular/configurator). Skipping Passkey Domain is a common cause of registration failures. -Since this project uses pnpm workspaces, you must build the SDK packages before running the example: +### 1. Passkey Domain (required) -```bash -# From the project root directory -$ pnpm build +Configurator → **Passkey** → **Domain Name**: + +```text +localhost ``` -This will generate the necessary distribution files that this example depends on. +Omit the port, then **Save**. -Now you need to go to this example folder: +> [!WARNING] +> If Passkey Domain is unset, `rp_getRegistrationOptions` fails with +> `Cannot find the entity config in the system.` (`code: -32012`). +> Creating a Client Key alone is not enough. + +### 2. Client Key + +1. Create a **Client Key** (Keys → Create a key → Client Key) +2. Under **Applicable Platforms → Web**, set **Allowed Domain** to `localhost` (no port) +3. Copy the Client Key and base Client URL into `.env` as above + +The Passkey Domain and Client Key Allowed Domain should both be `localhost` for this example. + +## Install and run + +From the repository root: ```bash -$ cd examples/circle-smart-account +pnpm install +pnpm build ``` -Once you are under the example folder, install all dependencies for the app: +Then from this folder: ```bash -$ pnpm install +cd examples/circle-smart-account +pnpm dev ``` -### Run the app +Open `http://localhost:5173/`. -To run the app locally: +## Smoke test -```bash -$ pnpm dev -``` +1. Enter a **username** that is **5–50 characters**, using only alphanumeric and `_@.:+-` (e.g. `Tester`, not `Test`) +2. Click **Register** and complete the passkey ceremony (browser / password manager) +3. Confirm a smart account **Address:** appears +4. Optional — send a user operation: + - Fund the smart account with **Polygon Amoy USDC** (faucet or transfer) + - **Address:** any Amoy EVM address you control (or the smart account itself) + - **Amount (USDC):** e.g. `0.01` + - Click **Send** and confirm a user operation hash / transaction hash appear -Now you should be able to see your app up and running in your browser at: `http://localhost:5173/`. +## Troubleshooting -### Important Notes +| Symptom | Likely cause | +| --- | --- | +| `Cannot find the entity config in the system` (`-32012`) | Passkey Domain not set in Configurator (use `localhost`) | +| `The username is invalid… 5 to 50 characters…` | Username too short or has disallowed characters | +| `InvalidProviderError` for `/polygonAmoy` | `VITE_CLIENT_URL` missing, includes `/polygonAmoy` already, or `.env` not in this folder | +| Client key / domain rejected | Client Key Allowed Domain must be `localhost` without a port | +| Passkey ceremony fails | Browser must support WebAuthn; use HTTPS or `localhost` | +| `Address mismatch` | SDK version older than `1.0.3` (MSCAUpgradable contract update) | +| User operation fails | Missing Amoy USDC balance, or paymaster / Circle client config | -- **Ensure the installed SDK version is greater than or equal to `1.0.3`:** +## Important notes - If you receive an error message that says `Address mismatch` in the example app, make sure you are using the correct version of the SDK as we updated the MSCAUpgradable smart contract implementation in version `1.0.3`. This error occurs when the SDK version is less than `1.0.3`. +- Ensure the installed SDK version is greater than or equal to `1.0.3` +- `.env` lives in `examples/circle-smart-account/`, not the repository root +- Official setup: [Create a modular wallet (console setup)](https://developers.circle.com/wallets/modular/console-setup) diff --git a/examples/circle-smart-account/package.json b/examples/circle-smart-account/package.json index d4227b0..1186c08 100644 --- a/examples/circle-smart-account/package.json +++ b/examples/circle-smart-account/package.json @@ -10,7 +10,7 @@ "@circle-fin/modular-wallets-core": "workspace:*", "react": "19.2.7", "react-dom": "19.2.7", - "viem": "2.23.13" + "viem": "2.45.3" }, "devDependencies": { "@types/react": "19.2.17", diff --git a/examples/eip-1193/package.json b/examples/eip-1193/package.json index 89e12d1..5336ccf 100644 --- a/examples/eip-1193/package.json +++ b/examples/eip-1193/package.json @@ -10,7 +10,7 @@ "@circle-fin/modular-wallets-core": "workspace:*", "react": "19.2.7", "react-dom": "19.2.7", - "viem": "2.23.13", + "viem": "2.45.3", "web3": "4.16.0" }, "devDependencies": { diff --git a/examples/passkey-recovery/package.json b/examples/passkey-recovery/package.json index 054fede..9d6d743 100644 --- a/examples/passkey-recovery/package.json +++ b/examples/passkey-recovery/package.json @@ -11,7 +11,7 @@ "bip39": "^3.1.0", "react": "19.2.7", "react-dom": "19.2.7", - "viem": "2.23.13" + "viem": "2.45.3" }, "devDependencies": { "@types/react": "19.2.17", diff --git a/packages/w3s-web-core-sdk/package.json b/packages/w3s-web-core-sdk/package.json index 0f4f56e..383e213 100644 --- a/packages/w3s-web-core-sdk/package.json +++ b/packages/w3s-web-core-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@circle-fin/modular-wallets-core", "description": "Serverless Typescript SDK", - "version": "1.0.14", + "version": "1.0.15", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", @@ -24,7 +24,7 @@ }, "dependencies": { "uuid": "^11.0.3", - "viem": "2.23.13", + "viem": "2.45.3", "web3": "4.16.0", "webauthn-p256": "0.0.5" }, diff --git a/packages/w3s-web-core-sdk/src/__mocks__/providers/eip-1193/EIP1193.Mock.ts b/packages/w3s-web-core-sdk/src/__mocks__/providers/eip-1193/EIP1193.Mock.ts index 0e5f15d..847da94 100644 --- a/packages/w3s-web-core-sdk/src/__mocks__/providers/eip-1193/EIP1193.Mock.ts +++ b/packages/w3s-web-core-sdk/src/__mocks__/providers/eip-1193/EIP1193.Mock.ts @@ -36,6 +36,39 @@ export const PersonalSignParams = [ '0x0f6Fed7D7526Aaa1692438AD1D77AaA0Ea9d0a56', ] +/** + * Mocks for EIP-1193 rpc params - personal_sign with Web3.js utf8ToHex("Hello World"). + */ +export const PersonalSignWeb3TextParams = [ + '0x48656c6c6f20576f726c64', + '0x0f6Fed7D7526Aaa1692438AD1D77AaA0Ea9d0a56', +] + +/** + * Mocks for EIP-1193 rpc params - personal_sign with empty hex payload. + */ +export const PersonalSignEmptyHexParams = [ + '0x', + '0x0f6Fed7D7526Aaa1692438AD1D77AaA0Ea9d0a56', +] + +/** + * Mocks for EIP-1193 rpc params - personal_sign with a 1-byte payload. + */ +export const PersonalSignOneByteParams = [ + '0x01', + '0x0f6Fed7D7526Aaa1692438AD1D77AaA0Ea9d0a56', +] + +/** + * Mocks for EIP-1193 rpc params - personal_sign with a lowercase account address. + * Same account as PersonalSignParams (mixed-case / EIP-55), all-lowercase form. + */ +export const PersonalSignLowercaseAddressParams = [ + '0xdeadbeef', + '0x0f6fed7d7526aaa1692438ad1d77aaa0ea9d0a56', +] + /** * Mocks for EIP-1193 rpc responses - personal_sign. */ @@ -133,27 +166,37 @@ export const SignTypedDataV4WrongAddressParams = [ /** * Mocks for EIP-1193 rpc params - eth_signTypedData_v4. */ +const signTypedDataV4TypedData = { + domain: { + name: 'MockDomain', + version: '1', + chainId: 1, + verifyingContract: '0x9876543210fedcba9876543210fedcba98765432', + }, + message: { + content: 'This is a test message', + }, + primaryType: 'Message', + types: { + EIP712Domain: [ + { name: 'name', type: 'string' }, + { name: 'version', type: 'string' }, + { name: 'chainId', type: 'uint256' }, + { name: 'verifyingContract', type: 'address' }, + ], + Message: [{ name: 'content', type: 'string' }], + }, +} + export const SignTypedDataV4Params = [ '0x0f6Fed7D7526Aaa1692438AD1D77AaA0Ea9d0a56', - { - domain: { - name: 'MockDomain', - version: '1', - chainId: 1, - verifyingContract: '0x9876543210fedcba9876543210fedcba98765432', - }, - message: { - content: 'This is a test message', - }, - primaryType: 'Message', - types: { - EIP712Domain: [ - { name: 'name', type: 'string' }, - { name: 'version', type: 'string' }, - { name: 'chainId', type: 'uint256' }, - { name: 'verifyingContract', type: 'address' }, - ], - Message: [{ name: 'content', type: 'string' }], - }, - }, + signTypedDataV4TypedData, +] + +/** + * Mocks for EIP-1193 rpc params - eth_signTypedData_v4 with JSON string typed data. + */ +export const SignTypedDataV4StringParams = [ + '0x0f6Fed7D7526Aaa1692438AD1D77AaA0Ea9d0a56', + JSON.stringify(signTypedDataV4TypedData), ] diff --git a/packages/w3s-web-core-sdk/src/__tests__/accounts/implementations/webAuthnSign.test.ts b/packages/w3s-web-core-sdk/src/__tests__/accounts/implementations/webAuthnSign.test.ts index db07a28..d156fbc 100644 --- a/packages/w3s-web-core-sdk/src/__tests__/accounts/implementations/webAuthnSign.test.ts +++ b/packages/w3s-web-core-sdk/src/__tests__/accounts/implementations/webAuthnSign.test.ts @@ -16,6 +16,7 @@ * limitations under the License. */ +import { decodeAbiParameters, stringToHex, type Hex } from 'viem' import { toWebAuthnAccount, type WebAuthnAccount, @@ -27,6 +28,7 @@ import { MockReplaySafeHash, toPasskeyTransport, } from '../../../__mocks__' +import { AUTHENTICATOR_DATA } from '../../../abis' import { toWebAuthnCredential, webAuthnSign } from '../../../accounts' import { AccountType, @@ -45,20 +47,147 @@ describe('Accounts > implementations > webAuthnSign', () => { mode: WebAuthnMode.Login, } + const hash = MockReplaySafeHash[AccountType.WebAuthn] + let owner: WebAuthnAccount + const createOwnerWithSignResult = ( + webauthn: Record, + signature: Hex = '0x', + ): WebAuthnAccount => ({ + ...owner, + sign: jest.fn().mockResolvedValue({ signature, webauthn }), + }) + + const validWebAuthnMetadata = { + authenticatorData: '0x' as Hex, + clientDataJSON: '{}', + challengeIndex: 23, + typeIndex: 1, + userVerificationRequired: true, + } + beforeEach(async () => { mockNavigatorGet.mockResolvedValue(LoginCredentialMock) const credential = await toWebAuthnCredential(loginParameters) owner = toWebAuthnAccount({ credential }) }) - it('should return signature', async () => { + it('should call owner.sign with the hash', async () => { + const sign = jest.spyOn(owner, 'sign') + + await webAuthnSign({ owner, hash }) + + expect(sign).toHaveBeenCalledWith({ hash }) + }) + + it('should return an ABI-encoded WebAuthn signature', async () => { + const signature = await webAuthnSign({ owner, hash }) + + expect(signature).toBe(MockInternalSignResult) + }) + + it('should encode requireUserVerification as true for passkey signatures', async () => { + const signature = await webAuthnSign({ owner, hash }) + + const [{ webAuthnData }] = decodeAbiParameters( + AUTHENTICATOR_DATA, + signature, + ) + + expect(webAuthnData.requireUserVerification).toBe(true) + expect(webAuthnData.challengeIndex).toBe(23n) + expect(webAuthnData.typeIndex).toBe(1n) + }) + + it('should throw when WebAuthn metadata omits challengeIndex', async () => { + const ownerWithIncompleteMetadata = createOwnerWithSignResult({ + ...validWebAuthnMetadata, + challengeIndex: undefined, + }) + + await expect( + webAuthnSign({ + owner: ownerWithIncompleteMetadata, + hash, + }), + ).rejects.toThrow( + 'WebAuthn signature metadata missing challengeIndex or typeIndex.', + ) + }) + + it('should throw when WebAuthn metadata omits typeIndex', async () => { + const ownerWithIncompleteMetadata = createOwnerWithSignResult({ + ...validWebAuthnMetadata, + typeIndex: undefined, + }) + + await expect( + webAuthnSign({ + owner: ownerWithIncompleteMetadata, + hash, + }), + ).rejects.toThrow( + 'WebAuthn signature metadata missing challengeIndex or typeIndex.', + ) + }) + + it('should throw when WebAuthn metadata omits userVerificationRequired', async () => { + const ownerWithIncompleteMetadata = createOwnerWithSignResult({ + authenticatorData: '0x', + clientDataJSON: '{}', + challengeIndex: 23, + typeIndex: 1, + }) + + await expect( + webAuthnSign({ + owner: ownerWithIncompleteMetadata, + hash, + }), + ).rejects.toThrow( + 'WebAuthn signature metadata requires userVerificationRequired to be true.', + ) + }) + + it('should throw when WebAuthn metadata has userVerificationRequired false', async () => { + const ownerWithIncompleteMetadata = createOwnerWithSignResult({ + ...validWebAuthnMetadata, + userVerificationRequired: false, + }) + + await expect( + webAuthnSign({ + owner: ownerWithIncompleteMetadata, + hash, + }), + ).rejects.toThrow( + 'WebAuthn signature metadata requires userVerificationRequired to be true.', + ) + }) + + it('should pass authenticatorData and clientDataJSON through to encoding', async () => { + const signResult = await owner.sign({ hash }) + const ownerWithSignResult = createOwnerWithSignResult( + signResult.webauthn, + signResult.signature, + ) + const signature = await webAuthnSign({ - owner, - hash: MockReplaySafeHash[AccountType.WebAuthn], + owner: ownerWithSignResult, + hash, }) - expect(signature).toBe(MockInternalSignResult) + const [{ webAuthnData }] = decodeAbiParameters( + AUTHENTICATOR_DATA, + signature, + ) + + expect(webAuthnData.authenticatorData).toBe( + signResult.webauthn.authenticatorData, + ) + expect(webAuthnData.clientDataJSON).toBe( + stringToHex(signResult.webauthn.clientDataJSON), + ) }) }) diff --git a/packages/w3s-web-core-sdk/src/__tests__/clients/createRpClient.test.ts b/packages/w3s-web-core-sdk/src/__tests__/clients/createRpClient.test.ts index 7544c73..62d0853 100644 --- a/packages/w3s-web-core-sdk/src/__tests__/clients/createRpClient.test.ts +++ b/packages/w3s-web-core-sdk/src/__tests__/clients/createRpClient.test.ts @@ -35,6 +35,7 @@ describe('Clients > createRpClient', () => { "cacheTime": 4000, "ccipRead": undefined, "chain": undefined, + "dataSuffix": undefined, "extend": [Function], "getLoginOptions": [Function], "getLoginVerification": [Function], diff --git a/packages/w3s-web-core-sdk/src/__tests__/clients/toCircleModularWalletClient.test.ts b/packages/w3s-web-core-sdk/src/__tests__/clients/toCircleModularWalletClient.test.ts index 3a2ecc9..94d66cb 100644 --- a/packages/w3s-web-core-sdk/src/__tests__/clients/toCircleModularWalletClient.test.ts +++ b/packages/w3s-web-core-sdk/src/__tests__/clients/toCircleModularWalletClient.test.ts @@ -46,11 +46,13 @@ describe('Clients > toCircleModularWalletClient', () => { "createContractEventFilter": [Function], "createEventFilter": [Function], "createPendingTransactionFilter": [Function], + "dataSuffix": undefined, "estimateContractGas": [Function], "estimateFeesPerGas": [Function], "estimateGas": [Function], "estimateMaxPriorityFeePerGas": [Function], "extend": [Function], + "fillTransaction": [Function], "getAddress": [Function], "getAddressMapping": [Function], "getBalance": [Function], @@ -88,12 +90,14 @@ describe('Clients > toCircleModularWalletClient', () => { "readContract": [Function], "request": [Function], "sendRawTransaction": [Function], + "sendRawTransactionSync": [Function], "simulate": [Function], "simulateBlocks": [Function], "simulateCalls": [Function], "simulateContract": [Function], "type": "publicClient", "uninstallFilter": [Function], + "verifyHash": [Function], "verifyMessage": [Function], "verifySiweMessage": [Function], "verifyTypedData": [Function], diff --git a/packages/w3s-web-core-sdk/src/__tests__/providers/eip-1193/provider.test.ts b/packages/w3s-web-core-sdk/src/__tests__/providers/eip-1193/provider.test.ts index cf7679f..ea42e6c 100644 --- a/packages/w3s-web-core-sdk/src/__tests__/providers/eip-1193/provider.test.ts +++ b/packages/w3s-web-core-sdk/src/__tests__/providers/eip-1193/provider.test.ts @@ -16,7 +16,7 @@ * limitations under the License. */ -import { createPublicClient, http } from 'viem' +import { createPublicClient, http, stringToHex, type Hex } from 'viem' import { createBundlerClient, toWebAuthnAccount, @@ -36,12 +36,17 @@ import { MockSendTransactionResponse, MockSendUserOperationResponse, MockWaitForUserOperationReceiptResponse, + PersonalSignLowercaseAddressParams, + PersonalSignEmptyHexParams, + PersonalSignOneByteParams, PersonalSignParams, PersonalSignResponse, + PersonalSignWeb3TextParams, PersonalSignWrongAddressParams, SendTransactionParams, SendTransactionToAddressMissingParams, SignTypedDataV4Params, + SignTypedDataV4StringParams, SignTypedDataV4WrongAddressParams, } from '../../../__mocks__/providers/eip-1193' import { toCircleSmartAccount, toWebAuthnCredential } from '../../../accounts' @@ -198,13 +203,104 @@ describe('Providers > eip-1193 > EIP1193Provider > rpc methods', () => { method: 'personal_sign', params: PersonalSignParams, } + const signMessageSpy = jest.spyOn(account, 'signMessage') const response = await provider.request< string, typeof PersonalSignResponse >(mockPayload) + expect(signMessageSpy).toHaveBeenCalledWith({ + message: { raw: PersonalSignParams[0] }, + }) + expect(signMessageSpy).not.toHaveBeenCalledWith({ + message: PersonalSignParams[0], + }) expect(response).toEqual(PersonalSignResponse) + + signMessageSpy.mockRestore() + }) + + it('should accept lowercase addresses for personal_sign when the account is mixed-case', async () => { + const mockPayload = { + method: 'personal_sign', + params: PersonalSignLowercaseAddressParams, + } + const signMessageSpy = jest.spyOn(account, 'signMessage') + + const response = await provider.request< + string, + typeof PersonalSignResponse + >(mockPayload) + + expect(signMessageSpy).toHaveBeenCalledWith({ + message: { raw: PersonalSignLowercaseAddressParams[0] }, + }) + expect(response).toEqual(PersonalSignResponse) + + signMessageSpy.mockRestore() + }) + + it('should sign Web3.js utf8ToHex text payloads as raw bytes for personal_sign', async () => { + const mockSignature = '0xweb3-personal-sign' as Hex + const mockPayload = { + method: 'personal_sign', + params: PersonalSignWeb3TextParams, + } + const signMessageSpy = jest + .spyOn(account, 'signMessage') + .mockResolvedValue(mockSignature) + + expect(PersonalSignWeb3TextParams[0]).toEqual(stringToHex('Hello World')) + + const response = await provider.request(mockPayload) + + expect(signMessageSpy).toHaveBeenCalledWith({ + message: { raw: PersonalSignWeb3TextParams[0] }, + }) + expect(signMessageSpy).not.toHaveBeenCalledWith({ + message: PersonalSignWeb3TextParams[0], + }) + expect(response).toEqual({ + result: mockSignature, + jsonrpc: undefined, + id: undefined, + }) + + signMessageSpy.mockRestore() + }) + + it('should sign empty and one-byte hex payloads as raw bytes for personal_sign', async () => { + const mockSignature = '0xedge-personal-sign' as Hex + const signMessageSpy = jest + .spyOn(account, 'signMessage') + .mockResolvedValue(mockSignature) + + for (const params of [ + PersonalSignEmptyHexParams, + PersonalSignOneByteParams, + ]) { + signMessageSpy.mockClear() + + const response = await provider.request({ + method: 'personal_sign', + params, + }) + + expect(signMessageSpy).toHaveBeenCalledWith({ + message: { raw: params[0] }, + }) + expect(signMessageSpy).not.toHaveBeenCalledWith({ + message: params[0], + }) + expect(response).toEqual({ + result: mockSignature, + jsonrpc: undefined, + id: undefined, + }) + } + + signMessageSpy.mockRestore() }) it('should throw an error when the to parameter is not provided for eth_sendTransaction', async () => { @@ -395,6 +491,31 @@ describe('Providers > eip-1193 > EIP1193Provider > rpc methods', () => { expect(response).toEqual(PersonalSignResponse) }) + it('should return the correct response for eth_signTypedData_v4 when typed data is a JSON string', async () => { + const mockPayload = { + method: 'eth_signTypedData_v4', + params: SignTypedDataV4StringParams, + } + + const response = await provider.request< + string, + typeof PersonalSignResponse + >(mockPayload) + + expect(response).toEqual(PersonalSignResponse) + }) + + it('should throw an error when eth_signTypedData_v4 typed data is invalid JSON', async () => { + const mockPayload = { + method: 'eth_signTypedData_v4', + params: [SignTypedDataV4Params[0], 'not-valid-json'], + } + + await expect(() => provider.request(mockPayload)).rejects.toThrow( + new Error('Invalid typed data: expected a valid JSON string or object'), + ) + }) + it('should forward the rpc requests to the bundler client when the method is not handled by itself', async () => { const mockPayload = { method: 'unsupported_method', diff --git a/packages/w3s-web-core-sdk/src/__tests__/utils/signature/toWebAuthnSignature.test.ts b/packages/w3s-web-core-sdk/src/__tests__/utils/signature/toWebAuthnSignature.test.ts new file mode 100644 index 0000000..d910d0f --- /dev/null +++ b/packages/w3s-web-core-sdk/src/__tests__/utils/signature/toWebAuthnSignature.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026, Circle Internet Group, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { decodeAbiParameters, stringToHex, type Hex } from 'viem' + +import { AUTHENTICATOR_DATA } from '../../../abis' +import { toWebAuthnSignature } from '../../../utils' + +import type { WebAuthnData } from 'webauthn-p256' + +describe('Utils > signature > toWebAuthnSignature', () => { + // Valid compact P256 signature (r || s) from webauthn-p256's parseSignature tests. + const signature: Hex = + '0x16d6f4bd3231c71c5e58927b9cf2ee701df03b52e3db71efc03d1139122f854f67f32a4fcb17b07ab9b7755b61e999b99139074fc8e1aa6d33d25beccbb2fbd4' + + const baseWebAuthn: Omit = { + authenticatorData: + '0x49960de5880e8c687434170f6476605b8fe4aeb9a28632c7995cf3ba831d97630500000000', + clientDataJSON: + '{"type":"webauthn.get","challenge":"test","origin":"http://localhost"}', + challengeIndex: 23, + typeIndex: 1, + } + + it.each([true, false])( + 'should encode requireUserVerification as %s from webauthn.userVerificationRequired', + (userVerificationRequired) => { + const encoded = toWebAuthnSignature({ + signature, + webauthn: { + ...baseWebAuthn, + userVerificationRequired, + }, + }) + + const [{ webAuthnData }] = decodeAbiParameters( + AUTHENTICATOR_DATA, + encoded, + ) + + expect(webAuthnData.requireUserVerification).toBe( + userVerificationRequired, + ) + expect(webAuthnData.challengeIndex).toBe(23n) + expect(webAuthnData.typeIndex).toBe(1n) + expect(webAuthnData.authenticatorData).toBe( + baseWebAuthn.authenticatorData, + ) + expect(webAuthnData.clientDataJSON).toBe( + stringToHex(baseWebAuthn.clientDataJSON), + ) + }, + ) +}) diff --git a/packages/w3s-web-core-sdk/src/accounts/implementations/toCircleSmartAccount.ts b/packages/w3s-web-core-sdk/src/accounts/implementations/toCircleSmartAccount.ts index 4f240bd..c6eecfb 100644 --- a/packages/w3s-web-core-sdk/src/accounts/implementations/toCircleSmartAccount.ts +++ b/packages/w3s-web-core-sdk/src/accounts/implementations/toCircleSmartAccount.ts @@ -39,6 +39,7 @@ import { UPGRADABLE_MSCA as upgradableMsca, } from '../../constants' import { + type CircleSmartAccountImplementation, type ToCircleSmartAccountParameters, type ToCircleSmartAccountReturnType, } from '../../types' @@ -89,8 +90,10 @@ export async function toCircleSmartAccount( walletAddress = wallet.address } + // viem 2.45 narrowed `toSmartAccount`'s client generic; our modular wallet + // client satisfies the contract at runtime. return toSmartAccount({ - client, + client: client as CircleSmartAccountImplementation['client'], entryPoint, extend: { abi, factory }, getAddress: async function (): Promise
{ @@ -227,5 +230,6 @@ export async function toCircleSmartAccount( }) }, }, + // viem 2.45's inferred return type omits Circle-specific `extend` metadata. }) } diff --git a/packages/w3s-web-core-sdk/src/accounts/implementations/webAuthnSign.ts b/packages/w3s-web-core-sdk/src/accounts/implementations/webAuthnSign.ts index b81733a..0bf5b53 100644 --- a/packages/w3s-web-core-sdk/src/accounts/implementations/webAuthnSign.ts +++ b/packages/w3s-web-core-sdk/src/accounts/implementations/webAuthnSign.ts @@ -46,10 +46,28 @@ export const webAuthnSign = async ({ hash, }) + const { challengeIndex, typeIndex, userVerificationRequired } = webauthn + if (challengeIndex === undefined || typeIndex === undefined) { + throw new Error( + 'WebAuthn signature metadata missing challengeIndex or typeIndex.', + ) + } + if (userVerificationRequired !== true) { + throw new Error( + 'WebAuthn signature metadata requires userVerificationRequired to be true.', + ) + } + return Promise.resolve( toWebAuthnSignature({ signature, - webauthn, + webauthn: { + authenticatorData: webauthn.authenticatorData, + clientDataJSON: webauthn.clientDataJSON, + challengeIndex, + typeIndex, + userVerificationRequired, + }, }), ) } diff --git a/packages/w3s-web-core-sdk/src/providers/eip-1193/provider.ts b/packages/w3s-web-core-sdk/src/providers/eip-1193/provider.ts index d627742..bcbd471 100644 --- a/packages/w3s-web-core-sdk/src/providers/eip-1193/provider.ts +++ b/packages/w3s-web-core-sdk/src/providers/eip-1193/provider.ts @@ -16,6 +16,7 @@ * limitations under the License. */ +import { isAddress, isAddressEqual } from 'viem' import { type BundlerClient } from 'viem/_types/account-abstraction' import { BaseProvider } from '../base' @@ -76,7 +77,7 @@ export default class EIP1193Provider< await this.validateAddress(address) const result = await this.bundlerClient.account!.signMessage({ - message: challenge, + message: { raw: challenge }, }) return this.getResponse(result, payload) @@ -114,13 +115,29 @@ export default class EIP1193Provider< return this.getResponse(receipt, payload) } case 'eth_signTypedData_v4': { - const [address, typedData] = params as [ + const [address, typedDataParam] = params as [ Hex, - TypedDataDefinition, + string | TypedDataDefinition, ] await this.validateAddress(address) + let typedData: TypedDataDefinition + if (typeof typedDataParam === 'string') { + try { + typedData = JSON.parse(typedDataParam) as TypedDataDefinition< + TypedData, + string + > + } catch { + throw new Error( + 'Invalid typed data: expected a valid JSON string or object', + ) + } + } else { + typedData = typedDataParam + } + const result = await this.bundlerClient.account!.signTypedData(typedData) @@ -141,7 +158,12 @@ export default class EIP1193Provider< private async validateAddress(address: Hex) { const clientAddress = await this.bundlerClient.account!.getAddress() - if (clientAddress !== address) { + // Web3.js checksums addresses before personal_sign / eth_signTypedData_v4; + // compare case-insensitively so EIP-55 casing does not fail validation. + if ( + !isAddress(address, { strict: false }) || + !isAddressEqual(clientAddress, address) + ) { throw new Error('Invalid account') } } diff --git a/packages/w3s-web-core-sdk/src/utils/signature/toWebAuthnSignature.ts b/packages/w3s-web-core-sdk/src/utils/signature/toWebAuthnSignature.ts index cc0ea9d..89819e2 100644 --- a/packages/w3s-web-core-sdk/src/utils/signature/toWebAuthnSignature.ts +++ b/packages/w3s-web-core-sdk/src/utils/signature/toWebAuthnSignature.ts @@ -95,7 +95,7 @@ export function toWebAuthnSignature({ clientDataJSON: stringToHex(webauthn.clientDataJSON), challengeIndex: BigInt(webauthn.challengeIndex), typeIndex: BigInt(webauthn.typeIndex), - requireUserVerification: true, + requireUserVerification: webauthn.userVerificationRequired, }, r, s, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66367bc..f825b72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,8 +53,8 @@ importers: specifier: 19.2.7 version: 19.2.7(react@19.2.7) viem: - specifier: 2.23.13 - version: 2.23.13(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@6.0.6)(zod@3.24.2) + specifier: 2.45.3 + version: 2.45.3(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@6.0.6)(zod@4.0.5) devDependencies: '@types/react': specifier: 19.2.17 @@ -121,8 +121,8 @@ importers: specifier: 19.2.7 version: 19.2.7(react@19.2.7) viem: - specifier: 2.23.13 - version: 2.23.13(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@6.0.6)(zod@3.24.2) + specifier: 2.45.3 + version: 2.45.3(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@6.0.6)(zod@3.24.2) web3: specifier: 4.16.0 version: 4.16.0(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@6.0.6)(zod@3.24.2) @@ -158,8 +158,8 @@ importers: specifier: 19.2.7 version: 19.2.7(react@19.2.7) viem: - specifier: 2.23.13 - version: 2.23.13(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@6.0.6)(zod@3.24.2) + specifier: 2.45.3 + version: 2.45.3(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@6.0.6)(zod@4.0.5) devDependencies: '@types/react': specifier: 19.2.17 @@ -186,8 +186,8 @@ importers: specifier: ^11.0.3 version: 11.1.0 viem: - specifier: 2.23.13 - version: 2.23.13(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@6.0.6)(zod@3.24.2) + specifier: 2.45.3 + version: 2.45.3(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@6.0.6)(zod@3.24.2) web3: specifier: 4.16.0 version: 4.16.0(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@6.0.6)(zod@3.24.2) @@ -2370,18 +2370,12 @@ packages: '@scure/bip32@1.4.0': resolution: {integrity: sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==} - '@scure/bip32@1.6.2': - resolution: {integrity: sha512-t96EPDMbtGgtb7onKKqxRLfE5g05k7uHnHRM2xdE6BP/ZmxaLtPek4J4KfVn/90IQNrU1IOAqMgiDtUdtbe3nw==} - '@scure/bip32@1.7.0': resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} '@scure/bip39@1.3.0': resolution: {integrity: sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==} - '@scure/bip39@1.5.4': - resolution: {integrity: sha512-TFM4ni0vKvCfBpohoh+/lY05i9gRbSwXWngAsF4CABQxoaOHijxuaZ2R6cStDQ5CHtHO9aGJTr4ksVJASRRyMA==} - '@scure/bip39@1.6.0': resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} @@ -3046,17 +3040,6 @@ packages: zod: optional: true - abitype@1.0.8: - resolution: {integrity: sha512-ZeiI6h3GnW06uYDLx0etQtX/p8E24UaHHBj57RSjK7YBFe7iuVn07EDpOeP451D06sF27VOz9JJPlIKJmXgkEg==} - peerDependencies: - typescript: '>=5.0.4' - zod: ^3 >=3.22.0 - peerDependenciesMeta: - typescript: - optional: true - zod: - optional: true - abitype@1.2.3: resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==} peerDependencies: @@ -4671,11 +4654,6 @@ packages: peerDependencies: ws: '*' - isows@1.0.6: - resolution: {integrity: sha512-lPHCayd40oW98/I0uvgaHKWCSvkzY27LjWLbtzOm64yQ+G3Q5npjjbdppU65iZXkK1Zt+kH9pfegli0AYfwYYw==} - peerDependencies: - ws: '*' - isows@1.0.7: resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} peerDependencies: @@ -6707,14 +6685,6 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - viem@2.23.13: - resolution: {integrity: sha512-f3RkcrzGhU79GfBb9GHUL0m3e3LUsNudXIQTFp4fit5hUGb0ew9KOYZ6cCY5d4Melj3noBy2zq0K2fV+mp+Cpg==} - peerDependencies: - typescript: '>=5.0.4' - peerDependenciesMeta: - typescript: - optional: true - viem@2.45.3: resolution: {integrity: sha512-axOD7rIbGiDHHA1MHKmpqqTz3CMCw7YpE/FVypddQMXL5i364VkNZh9JeEJH17NO484LaZUOMueo35IXyL76Mw==} peerDependencies: @@ -6954,18 +6924,6 @@ packages: utf-8-validate: optional: true - ws@8.18.1: - resolution: {integrity: sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.18.3: resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} @@ -10203,12 +10161,6 @@ snapshots: '@noble/hashes': 1.4.0 '@scure/base': 1.1.9 - '@scure/bip32@1.6.2': - dependencies: - '@noble/curves': 1.8.1 - '@noble/hashes': 1.7.1 - '@scure/base': 1.2.6 - '@scure/bip32@1.7.0': dependencies: '@noble/curves': 1.9.7 @@ -10220,11 +10172,6 @@ snapshots: '@noble/hashes': 1.4.0 '@scure/base': 1.1.9 - '@scure/bip39@1.5.4': - dependencies: - '@noble/hashes': 1.7.1 - '@scure/base': 1.2.6 - '@scure/bip39@1.6.0': dependencies: '@noble/hashes': 1.8.0 @@ -11429,15 +11376,15 @@ snapshots: optionalDependencies: zod: 3.24.2 - abitype@1.0.8(typescript@5.8.2)(zod@3.24.2): + abitype@1.2.3(typescript@5.8.2)(zod@3.22.4): optionalDependencies: typescript: 5.8.2 - zod: 3.24.2 + zod: 3.22.4 - abitype@1.2.3(typescript@5.8.2)(zod@3.22.4): + abitype@1.2.3(typescript@5.8.2)(zod@3.24.2): optionalDependencies: typescript: 5.8.2 - zod: 3.22.4 + zod: 3.24.2 abitype@1.2.3(typescript@5.8.2)(zod@4.0.5): optionalDependencies: @@ -13295,10 +13242,6 @@ snapshots: dependencies: ws: 8.21.0(bufferutil@4.0.9)(utf-8-validate@6.0.6) - isows@1.0.6(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@6.0.6)): - dependencies: - ws: 8.18.1(bufferutil@4.0.9)(utf-8-validate@6.0.6) - isows@1.0.7(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.6)): dependencies: ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.6) @@ -14447,7 +14390,7 @@ snapshots: transitivePeerDependencies: - zod - ox@0.12.1(typescript@5.8.2)(zod@4.0.5): + ox@0.12.1(typescript@5.8.2)(zod@3.24.2): dependencies: '@adraffy/ens-normalize': 1.11.0 '@noble/ciphers': 1.3.0 @@ -14455,21 +14398,22 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.2.4(typescript@5.8.2)(zod@4.0.5) + abitype: 1.2.4(typescript@5.8.2)(zod@3.24.2) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.8.2 transitivePeerDependencies: - zod - ox@0.6.9(typescript@5.8.2)(zod@3.24.2): + ox@0.12.1(typescript@5.8.2)(zod@4.0.5): dependencies: '@adraffy/ens-normalize': 1.11.0 - '@noble/curves': 1.9.7 + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.2.4(typescript@5.8.2)(zod@3.24.2) + abitype: 1.2.4(typescript@5.8.2)(zod@4.0.5) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.8.2 @@ -15854,16 +15798,16 @@ snapshots: vary@1.1.2: optional: true - viem@2.23.13(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@6.0.6)(zod@3.24.2): + viem@2.45.3(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@6.0.6)(zod@3.22.4): dependencies: - '@noble/curves': 1.8.1 - '@noble/hashes': 1.7.1 - '@scure/bip32': 1.6.2 - '@scure/bip39': 1.5.4 - abitype: 1.0.8(typescript@5.8.2)(zod@3.24.2) - isows: 1.0.6(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@6.0.6)) - ox: 0.6.9(typescript@5.8.2)(zod@3.24.2) - ws: 8.18.1(bufferutil@4.0.9)(utf-8-validate@6.0.6) + '@noble/curves': 1.9.1 + '@noble/hashes': 1.8.0 + '@scure/bip32': 1.7.0 + '@scure/bip39': 1.6.0 + abitype: 1.2.3(typescript@5.8.2)(zod@3.22.4) + isows: 1.0.7(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.6)) + ox: 0.12.1(typescript@5.8.2)(zod@3.22.4) + ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.6) optionalDependencies: typescript: 5.8.2 transitivePeerDependencies: @@ -15871,15 +15815,15 @@ snapshots: - utf-8-validate - zod - viem@2.45.3(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@6.0.6)(zod@3.22.4): + viem@2.45.3(bufferutil@4.0.9)(typescript@5.8.2)(utf-8-validate@6.0.6)(zod@3.24.2): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.8.2)(zod@3.22.4) + abitype: 1.2.3(typescript@5.8.2)(zod@3.24.2) isows: 1.0.7(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.6)) - ox: 0.12.1(typescript@5.8.2)(zod@3.22.4) + ox: 0.12.1(typescript@5.8.2)(zod@3.24.2) ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.6) optionalDependencies: typescript: 5.8.2 @@ -16269,11 +16213,6 @@ snapshots: bufferutil: 4.0.9 utf-8-validate: 6.0.6 - ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@6.0.6): - optionalDependencies: - bufferutil: 4.0.9 - utf-8-validate: 6.0.6 - ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.6): optionalDependencies: bufferutil: 4.0.9 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 647ebeb..3b96947 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -32,7 +32,7 @@ catalogs: vite-plugin-node-polyfills: 0.21.0 web3: - viem: 2.23.13 + viem: 2.45.3 web3: 4.16.0 abitype: 1.2.4 web3-errors: 1.3.1 @@ -60,7 +60,6 @@ catalogs: dynamic: '@dynamic-labs/ethereum': 4.88.6 '@dynamic-labs/sdk-react-core': 4.88.6 - viem: 2.45.3 overrides: '@dynamic-labs/assert-package-version': 4.88.6