From ac5d93e71668fa48ae8627c73195e21829c69a23 Mon Sep 17 00:00:00 2001 From: cbe Date: Mon, 17 Nov 2025 20:03:16 -0800 Subject: [PATCH 1/5] feat: starting signing typed data demo will help as an example --- cspell-config/cspell-misc.txt | 1 + docs/sdk/client/typed-data-erc7739-demo.md | 69 +++++ .../components/DeploymentResultCard.vue | 41 +++ .../components/FindAddressesByPasskey.vue | 148 +++++++++++ .../demo-app/components/TypedDataErc7739.vue | 245 ++++++++++++++++++ examples/demo-app/pages/web-sdk-test.vue | 244 +---------------- 6 files changed, 516 insertions(+), 232 deletions(-) create mode 100644 docs/sdk/client/typed-data-erc7739-demo.md create mode 100644 examples/demo-app/components/DeploymentResultCard.vue create mode 100644 examples/demo-app/components/FindAddressesByPasskey.vue create mode 100644 examples/demo-app/components/TypedDataErc7739.vue diff --git a/cspell-config/cspell-misc.txt b/cspell-config/cspell-misc.txt index 7c0ce86c0..8756eb771 100644 --- a/cspell-config/cspell-misc.txt +++ b/cspell-config/cspell-misc.txt @@ -14,6 +14,7 @@ ethereum sepolia foundryup unpermitted +dapps // examples/bank-demo ctap diff --git a/docs/sdk/client/typed-data-erc7739-demo.md b/docs/sdk/client/typed-data-erc7739-demo.md new file mode 100644 index 000000000..86e588249 --- /dev/null +++ b/docs/sdk/client/typed-data-erc7739-demo.md @@ -0,0 +1,69 @@ +# EIP-712 + ERC-7739 Typed Data Demo (Web SDK) + +This document outlines how we add a concrete example of EIP-712 signing wrapped +with ERC-7739 and validate it on-chain via ERC-1271 in the demo app. + +## Goals + +- Demonstrate how a smart account (non-EOA) signs typed data following ERC-7739 + (nested EIP-712) and validates it via ERC-1271. +- Provide a reference for NFT marketplaces and dapps that need typed-data + signatures from smart accounts. +- Keep CI green (no changes required to Rust; JS/TS additions only). + +## Architecture Snapshot + +- Account-level validation is implemented by `ERC1271Handler` and validator + modules (EOA/WebAuthn) in `packages/erc4337-contracts`. +- ERC-7739 support is present via OZ draft utils and is referenced in + `ERC1271Handler`. +- Integration tests show the pattern for wrapping and validating typed data + using `viem/experimental/erc7739` and a mock caller. +- The demo contract `examples/demo-app/smart-contracts/ERC1271Caller.sol` + exposes `validateStruct(TestStruct,address,bytes)` for verification. + +## Implementation Plan + +1. Add a new section to `examples/demo-app/pages/web-sdk-test.vue` named "Typed + Data (ERC-7739)". +2. Build typed data (`types`, `primaryType`, `message`) for + `ERC1271Caller.TestStruct`. +3. Fetch the EIP-712 domain from the deployed `ERC1271Caller` via + `publicClient.getEip712Domain`, omitting `salt` for compatibility. +4. Sign typed data using the SSO connector + wagmi with ERC-7739 wrapping + (auth-server path extends with `erc7739Actions`). +5. Display the encoded signature and verify it on-chain with + `readContract(ERC1271Caller.validateStruct)` using the smart account address + as `signer`. +6. Show Valid/Invalid result and surface the `ERC1271Caller` address from + `examples/demo-app/forge-output-erc1271.json`. + +## References + +- `packages/erc4337-contracts/src/core/ERC1271Handler.sol` (uses ERC-7739, + forwards to validator via `isValidSignatureWithSender`). +- Tests: `packages/erc4337-contracts/test/integration/basic.test.ts` and + `passkey.test.ts` for ERC-7739 wrapping and validation. +- Existing demo pattern in `examples/demo-app/pages/index.vue` (typed-data + section & verification). + +## Gaps & Notes + +- `packages/sdk-4337` passkey/ecdsa/session accounts do not implement + `signTypedData`; use the auth-server wallet client path (which is extended + with `erc7739Actions`). +- Ensure a smart account is connected; `ERC1271Caller` expects + `IERC1271.isValidSignature` at the `signer` address. +- Domain salt is omitted to match current verification; align on a salt policy + later. + +## Deploy & Try + +- Deploy demo contracts (includes `ERC1271Caller`): + +```bash +nx run examples-demo-app:deploy:forge +``` + +- Start the demo app and open the Web SDK Test page. +- Use the new "Typed Data (ERC-7739)" section to sign & verify. diff --git a/examples/demo-app/components/DeploymentResultCard.vue b/examples/demo-app/components/DeploymentResultCard.vue new file mode 100644 index 000000000..0a0bfd708 --- /dev/null +++ b/examples/demo-app/components/DeploymentResultCard.vue @@ -0,0 +1,41 @@ + + + diff --git a/examples/demo-app/components/FindAddressesByPasskey.vue b/examples/demo-app/components/FindAddressesByPasskey.vue new file mode 100644 index 000000000..9c4299381 --- /dev/null +++ b/examples/demo-app/components/FindAddressesByPasskey.vue @@ -0,0 +1,148 @@ + + + diff --git a/examples/demo-app/components/TypedDataErc7739.vue b/examples/demo-app/components/TypedDataErc7739.vue new file mode 100644 index 000000000..a1993f8ee --- /dev/null +++ b/examples/demo-app/components/TypedDataErc7739.vue @@ -0,0 +1,245 @@ + + + diff --git a/examples/demo-app/pages/web-sdk-test.vue b/examples/demo-app/pages/web-sdk-test.vue index 53f39f12d..24531cc73 100644 --- a/examples/demo-app/pages/web-sdk-test.vue +++ b/examples/demo-app/pages/web-sdk-test.vue @@ -58,36 +58,7 @@ -
-

- Account Deployed Successfully! -

-
-
- User ID: - {{ deploymentResult.userId }} -
-
- Account ID (computed): - {{ deploymentResult.accountId }} -
-
- Account Address: - {{ deploymentResult.address }} -
-
- EOA Signer: - {{ deploymentResult.eoaSigner }} - (Anvil Rich Wallet #1) -
-
- Passkey Enabled: Yes -
-
-
+
- -
-

- Find Addresses by Passkey -

-

- Authenticate with a passkey to find all smart account addresses associated with it. -

- -
- - - - -
-
-
-

- Passkey Credential ID: -

- {{ findPasskeyCredentialId }} -
- -
-

- Origin Domain: -

- {{ findPasskeyOriginDomain }} -
- - -
-

- Associated Accounts: -

-
-
    -
  • - {{ index + 1 }}. {{ address }} -
  • -
-
-
-

- No accounts found for this passkey. -

-
-
-
-
- - -
- Scan Error: -

- {{ findPasskeyScanError }} -

-
+ + -
- Search Error: -

- {{ findAddressesError }} -

-
-
-
+ +
(null); -const findAddressesError = ref(""); +// Find addresses by passkey state moved to component // Fund smart account parameters const fundParams = ref({ @@ -621,6 +491,8 @@ async function testWebSDK() { } } +/** Typed Data (ERC-7739) is now a separate component */ + /** * Load WebAuthn validator address from contracts.json */ @@ -922,99 +794,7 @@ async function registerPasskey() { } } -/** - * Scan/authenticate with a passkey and automatically find associated accounts - */ -async function scanPasskeyForFindAccounts() { - loading.value = true; - findPasskeyScanError.value = ""; - foundAddresses.value = null; - findAddressesError.value = ""; - findPasskeyScanned.value = false; - - try { - // eslint-disable-next-line no-console - console.log("Requesting WebAuthn authentication to scan passkey..."); - - // Create a challenge for authentication - const challenge = new Uint8Array(32); - crypto.getRandomValues(challenge); - - // Request authentication to get the credential ID - const credential = await navigator.credentials.get({ - publicKey: { - challenge, - timeout: 60000, - rpId: window.location.hostname, - userVerification: "required", - }, - }); - - if (!credential || credential.type !== "public-key") { - throw new Error("Failed to authenticate with passkey"); - } - - const pkCredential = credential as PublicKeyCredential; - - // Extract credential ID - const credentialId = new Uint8Array(pkCredential.rawId); - const credentialIdHex = `0x${Array.from(credentialId).map((b) => b.toString(16).padStart(2, "0")).join("")}`; - - // Set the scanned passkey details - findPasskeyCredentialId.value = credentialIdHex; - findPasskeyOriginDomain.value = window.location.origin; - findPasskeyScanned.value = true; - - // eslint-disable-next-line no-console - console.log("Passkey scanned successfully:"); - // eslint-disable-next-line no-console - console.log(" Credential ID:", credentialIdHex); - // eslint-disable-next-line no-console - console.log(" Origin:", window.location.origin); - - // Automatically find accounts for this passkey - // eslint-disable-next-line no-console - console.log("Finding accounts for passkey..."); - - // Load contracts configuration - const contracts = await loadContracts(); - - // Create public client - const publicClient = await createPublicClient(contracts); - - // eslint-disable-next-line no-console - console.log(" WebAuthn Validator:", contracts.webauthnValidator); - - // Call the findAddressesByPasskey action - const result = await findAddressesByPasskey({ - client: publicClient, - contracts: { - webauthnValidator: contracts.webauthnValidator as Address, - }, - passkey: { - credentialId: credentialIdHex as Hex, - originDomain: window.location.origin, - }, - }); - - foundAddresses.value = result.addresses; - - // eslint-disable-next-line no-console - console.log(` Found ${result.addresses.length} account(s):`, result.addresses); - } catch (err: unknown) { - // eslint-disable-next-line no-console - console.error("Failed to scan passkey or find accounts:", err); - - // Determine if error was during scan or search - if (!findPasskeyScanned.value) { - findPasskeyScanError.value = `Failed to scan passkey: ${err instanceof Error ? err.message : String(err)}`; - } else { - findAddressesError.value = `Failed to find accounts: ${err instanceof Error ? err.message : String(err)}`; - } - } finally { - loading.value = false; - } -} +/** Find addresses by passkey logic moved into FindAddressesByPasskey component */ // Fund the smart account with ETH from EOA wallet async function fundSmartAccount() { @@ -1107,8 +887,8 @@ async function computeAddress() { throw new Error("Please fill in all address parameters correctly"); } - // Use the WASM function to compute the account ID - const accountId = compute_account_id(addressParams.value.userId); + // Compute the account ID using the SDK helper + const accountId = generateAccountId(addressParams.value.userId); // eslint-disable-next-line no-console console.log("Computing address with parameters:"); From 19802edaf56d37d3386830fbcc7cbdbc11dfd7cf Mon Sep 17 00:00:00 2001 From: cbe Date: Tue, 18 Nov 2025 10:34:53 -0800 Subject: [PATCH 2/5] fix: failing test with some setup should avoid using auth server --- docs/sdk/client/typed-data-erc7739-demo.md | 31 +++++++++-- .../demo-app/components/TypedDataErc7739.vue | 45 ++++++++++++---- examples/demo-app/foundry.toml | 4 ++ examples/demo-app/package.json | 2 +- examples/demo-app/project.json | 43 ++++++++++++++- examples/demo-app/remappings.txt | 1 + .../demo-app/scripts/deploy-erc1271-caller.sh | 54 +++++++++++++++++++ examples/demo-app/tests/web-sdk-test.spec.ts | 40 +++++++++++++- pnpm-lock.yaml | 25 ++++++--- 9 files changed, 222 insertions(+), 23 deletions(-) create mode 100644 examples/demo-app/foundry.toml create mode 100644 examples/demo-app/remappings.txt create mode 100755 examples/demo-app/scripts/deploy-erc1271-caller.sh diff --git a/docs/sdk/client/typed-data-erc7739-demo.md b/docs/sdk/client/typed-data-erc7739-demo.md index 86e588249..4abd32749 100644 --- a/docs/sdk/client/typed-data-erc7739-demo.md +++ b/docs/sdk/client/typed-data-erc7739-demo.md @@ -59,11 +59,34 @@ with ERC-7739 and validate it on-chain via ERC-1271 in the demo app. ## Deploy & Try -- Deploy demo contracts (includes `ERC1271Caller`): +- This demo currently works only on Anvil (local EVM). + +- Option A: Deploy only the `ERC1271Caller` used by the demo (recommended for + this demo): + +```bash +anvil +nx run demo-app:deploy-erc1271-caller +``` + +- Option B (4337 dev flow on Anvil): Deploy 4337 factory + ERC1271Caller, then + run dev: ```bash -nx run examples-demo-app:deploy:forge +anvil +nx run demo-app:dev:erc4337:anvil ``` -- Start the demo app and open the Web SDK Test page. -- Use the new "Typed Data (ERC-7739)" section to sign & verify. +- Start the demo app and open the Web SDK Test page: + +```bash +nx run demo-app:dev +``` + +- In the "Typed Data (ERC-7739)" section, connect, sign, and verify on-chain. + +Notes: + +- Requires a local Anvil at `http://localhost:8545`. +- Uses an Anvil test private key for deployment (configurable via + `PRIVATE_KEY`). diff --git a/examples/demo-app/components/TypedDataErc7739.vue b/examples/demo-app/components/TypedDataErc7739.vue index a1993f8ee..c09f7bbfe 100644 --- a/examples/demo-app/components/TypedDataErc7739.vue +++ b/examples/demo-app/components/TypedDataErc7739.vue @@ -1,5 +1,8 @@ diff --git a/examples/demo-app/pages/web-sdk-test.vue b/examples/demo-app/pages/web-sdk-test.vue index 24531cc73..7a3bac8b1 100644 --- a/examples/demo-app/pages/web-sdk-test.vue +++ b/examples/demo-app/pages/web-sdk-test.vue @@ -104,7 +104,36 @@
- + + + +
+

+ E2E Bridge: Typed Data Actions +

+
+ + +
+
@@ -321,6 +350,8 @@ const testResult = ref(""); const error = ref(""); const loading = ref(false); const deploymentResult = ref(null); +// E2E bridge ref to call TypedDataErc7739 actions +const typedDataRef = ref<{ signErc7739?: () => void; verifyErc7739?: () => void } | null>(null); // Address computation parameters const addressParams = ref({ diff --git a/examples/demo-app/smart-contracts/ERC1271Caller.sol b/examples/demo-app/smart-contracts/ERC1271Caller.sol index 93a36ee3b..719936e84 100644 --- a/examples/demo-app/smart-contracts/ERC1271Caller.sol +++ b/examples/demo-app/smart-contracts/ERC1271Caller.sol @@ -34,4 +34,80 @@ contract ERC1271Caller is EIP712 { bytes4 magic = IERC1271(signer).isValidSignature(digest, signature); return magic == IERC1271.isValidSignature.selector; } + + /** + * Validates a precomputed digest against an ERC-1271 signer. + * This is useful for schemes like ERC-7739 where the digest is computed off-chain. + */ + function validateDigest(bytes32 digest, address signer, bytes calldata signature) external view returns (bool) { + require(signer != address(0), "Invalid signer address"); + bytes4 magic = IERC1271(signer).isValidSignature(digest, signature); + return magic == IERC1271.isValidSignature.selector; + } + + // --- DEBUG helpers --------------------------------------------------- + /// @notice Compute the EIP-712 struct hash for the provided TestStruct + function computeStructHash(TestStruct calldata testStruct) external pure returns (bytes32) { + return + keccak256( + abi.encode( + keccak256("TestStruct(string message,uint256 value)"), + keccak256(bytes(testStruct.message)), + testStruct.value + ) + ); + } + + /// @notice Compute the TypedDataSign typed-data wrapper typehash, wrapper struct hash and final hash from Basic.t.sol + /// @dev This mirrors the assembly algorithm in Solady/Basic.t.sol for debugging parity + function computeTypedDataSignAndFinalHash( + TestStruct calldata testStruct, + string calldata contentsDescription, + string calldata verifierName, + string calldata verifierVersion, + uint256 verifierChainId, + address verifierContract, + bytes32 verifierSalt + ) external view returns (bytes32 typedDataSignTypehash, bytes32 wrapperStructHash, bytes32 finalHash) { + bytes32 structHash = keccak256( + abi.encode( + keccak256("TestStruct(string message,uint256 value)"), + keccak256(bytes(testStruct.message)), + testStruct.value + ) + ); + + // Build the TypedDataSign type string, appending the `contentsDescription` as in Basic.t.sol + bytes memory prefix = abi.encodePacked( + "TypedDataSign(", + "TestStruct contents,", + "string name,", + "string version,", + "uint256 chainId,", + "address verifyingContract,", + "bytes32 salt)", + contentsDescription + ); + + typedDataSignTypehash = keccak256(prefix); + + wrapperStructHash = keccak256( + abi.encode( + typedDataSignTypehash, + structHash, + keccak256(bytes(verifierName)), + keccak256(bytes(verifierVersion)), + verifierChainId, + uint256(uint160(verifierContract)), + verifierSalt + ) + ); + + finalHash = keccak256(abi.encodePacked(hex"1901", _domainSeparatorV4(), wrapperStructHash)); + } + + /// @notice Return this contract's EIP-712 domain separator (app domain) + function appDomainSeparator() external view returns (bytes32) { + return _domainSeparatorV4(); + } } diff --git a/examples/demo-app/tests/web-sdk-test.spec.ts b/examples/demo-app/tests/web-sdk-test.spec.ts index 16e643382..19a9afe21 100644 --- a/examples/demo-app/tests/web-sdk-test.spec.ts +++ b/examples/demo-app/tests/web-sdk-test.spec.ts @@ -640,7 +640,7 @@ test("Deploy account with session validator pre-installed", async ({ page }) => console.log("✅ Deploy with session validator test completed!"); }); -test.skip("Sign and verify ERC-7739 typed data via ERC-1271", async ({ page }) => { +test("Sign and verify ERC-7739 typed data via ERC-1271", async ({ page }) => { // Use Anvil account #10 for this test to avoid nonce conflicts await page.goto("/web-sdk-test?fundingAccount=10"); await expect(page.getByText("ZKSync SSO Web SDK Test")).toBeVisible(); @@ -657,21 +657,25 @@ test.skip("Sign and verify ERC-7739 typed data via ERC-1271", async ({ page }) = const typedDataSection = page.getByTestId("typed-data-section"); await expect(typedDataSection).toBeVisible({ timeout: 10000 }); - // If connect is required, connect first - const connectBtn = page.getByTestId("typeddata-connect"); - if (await connectBtn.isVisible()) { - await connectBtn.click(); - // Wait for connection to complete (sign button becomes visible) - await expect(page.getByTestId("typeddata-sign")).toBeVisible({ timeout: 10000 }); - } - - // Sign the ERC-7739 typed data - await page.getByTestId("typeddata-sign").click(); - await expect(page.getByText("Encoded Signature:")).toBeVisible({ timeout: 20000 }); + // Sign the ERC-7739 typed data (no connection needed with new implementation) + const tdSection = page.getByTestId("typed-data-section"); + await expect(tdSection).toBeVisible({ timeout: 15000 }); + // Ensure ERC1271 caller address is present before interacting (ensures readiness) + const callerAddr = tdSection.getByTestId("erc1271-caller-address"); + await expect(callerAddr).toBeVisible({ timeout: 30000 }); + // Small settle wait to allow client-side hydration to render actions + await page.waitForTimeout(250); + // Use E2E bridge to trigger sign to avoid internal gating + const signBridge = page.getByTestId("typeddata-sign-bridge"); + await expect(signBridge).toBeVisible({ timeout: 20000 }); + await signBridge.click(); + await expect(tdSection.getByTestId("typeddata-signature")).toBeVisible({ timeout: 20000 }); // Verify on-chain via ERC-1271 helper contract - await page.getByTestId("typeddata-verify").click(); - const result = page.getByTestId("typeddata-result"); + const verifyBridge = page.getByTestId("typeddata-verify-bridge"); + await expect(verifyBridge).toBeVisible({ timeout: 20000 }); + await verifyBridge.click(); + const result = tdSection.getByTestId("typeddata-verify-result"); await expect(result).toBeVisible({ timeout: 30000 }); await expect(result).toContainText("Valid"); diff --git a/examples/demo-app/utils/erc7739.ts b/examples/demo-app/utils/erc7739.ts new file mode 100644 index 000000000..5a2ca0313 --- /dev/null +++ b/examples/demo-app/utils/erc7739.ts @@ -0,0 +1,83 @@ +import { hashTypedData, wrapTypedDataSignature } from "viem/experimental/erc7739"; +import { pad, concatHex } from "viem"; +import type { TypedData, TypedDataDomain, Hex } from "viem"; + +export interface Erc7739TypedData { + domain: TypedDataDomain; + types: TypedData; + primaryType: string; + message: Record; +} + +export interface SignTypedDataErc7739Args { + typedData: Erc7739TypedData; + smartAccountAddress: Hex; + signer: (hash: Hex) => Promise; + chainId?: number; + validatorAddress?: Hex; +} + +export interface HashTypedDataErc7739Args { + typedData: Erc7739TypedData; + smartAccountAddress: Hex; + chainId?: number; +} + +export async function signTypedDataErc7739({ + typedData, + smartAccountAddress, + signer, + chainId = 1337, + validatorAddress, +}: SignTypedDataErc7739Args): Promise { + const verifierDomain: TypedDataDomain = { + chainId, + name: "zksync-sso-1271", + version: "1.0.0", + verifyingContract: smartAccountAddress, + salt: pad("0x", { size: 32 }), + }; + + const erc7739Data: Erc7739TypedData = { + domain: verifierDomain, + types: typedData.types, + primaryType: typedData.primaryType, + message: typedData.message, + }; + const hash = hashTypedData(erc7739Data); + const signature = await signer(hash); + + const signatureToWrap = validatorAddress + ? concatHex([validatorAddress, signature]) + : signature; + + return wrapTypedDataSignature({ + domain: typedData.domain, + types: typedData.types, + primaryType: typedData.primaryType, + message: typedData.message, + signature: signatureToWrap, + }); +} + +export function hashTypedDataErc7739({ + typedData, + smartAccountAddress, + chainId = 1337, +}: HashTypedDataErc7739Args): Hex { + const verifierDomain: TypedDataDomain = { + chainId, + name: "zksync-sso-1271", + version: "1.0.0", + verifyingContract: smartAccountAddress, + salt: pad("0x", { size: 32 }), + }; + + const erc7739Data: Erc7739TypedData = { + domain: verifierDomain, + types: typedData.types, + primaryType: typedData.primaryType, + message: typedData.message, + }; + return hashTypedData(erc7739Data); +} From 710a8e37f83ea4a23e6d84bf7a6d1c275d452698 Mon Sep 17 00:00:00 2001 From: cbe Date: Wed, 19 Nov 2025 11:14:30 -0800 Subject: [PATCH 4/5] fix: remove unused code and docs will come back to this later --- docs/sdk/client/typed-data-erc7739-demo.md | 92 ---------------------- examples/demo-app/project.json | 17 ---- 2 files changed, 109 deletions(-) delete mode 100644 docs/sdk/client/typed-data-erc7739-demo.md diff --git a/docs/sdk/client/typed-data-erc7739-demo.md b/docs/sdk/client/typed-data-erc7739-demo.md deleted file mode 100644 index 4abd32749..000000000 --- a/docs/sdk/client/typed-data-erc7739-demo.md +++ /dev/null @@ -1,92 +0,0 @@ -# EIP-712 + ERC-7739 Typed Data Demo (Web SDK) - -This document outlines how we add a concrete example of EIP-712 signing wrapped -with ERC-7739 and validate it on-chain via ERC-1271 in the demo app. - -## Goals - -- Demonstrate how a smart account (non-EOA) signs typed data following ERC-7739 - (nested EIP-712) and validates it via ERC-1271. -- Provide a reference for NFT marketplaces and dapps that need typed-data - signatures from smart accounts. -- Keep CI green (no changes required to Rust; JS/TS additions only). - -## Architecture Snapshot - -- Account-level validation is implemented by `ERC1271Handler` and validator - modules (EOA/WebAuthn) in `packages/erc4337-contracts`. -- ERC-7739 support is present via OZ draft utils and is referenced in - `ERC1271Handler`. -- Integration tests show the pattern for wrapping and validating typed data - using `viem/experimental/erc7739` and a mock caller. -- The demo contract `examples/demo-app/smart-contracts/ERC1271Caller.sol` - exposes `validateStruct(TestStruct,address,bytes)` for verification. - -## Implementation Plan - -1. Add a new section to `examples/demo-app/pages/web-sdk-test.vue` named "Typed - Data (ERC-7739)". -2. Build typed data (`types`, `primaryType`, `message`) for - `ERC1271Caller.TestStruct`. -3. Fetch the EIP-712 domain from the deployed `ERC1271Caller` via - `publicClient.getEip712Domain`, omitting `salt` for compatibility. -4. Sign typed data using the SSO connector + wagmi with ERC-7739 wrapping - (auth-server path extends with `erc7739Actions`). -5. Display the encoded signature and verify it on-chain with - `readContract(ERC1271Caller.validateStruct)` using the smart account address - as `signer`. -6. Show Valid/Invalid result and surface the `ERC1271Caller` address from - `examples/demo-app/forge-output-erc1271.json`. - -## References - -- `packages/erc4337-contracts/src/core/ERC1271Handler.sol` (uses ERC-7739, - forwards to validator via `isValidSignatureWithSender`). -- Tests: `packages/erc4337-contracts/test/integration/basic.test.ts` and - `passkey.test.ts` for ERC-7739 wrapping and validation. -- Existing demo pattern in `examples/demo-app/pages/index.vue` (typed-data - section & verification). - -## Gaps & Notes - -- `packages/sdk-4337` passkey/ecdsa/session accounts do not implement - `signTypedData`; use the auth-server wallet client path (which is extended - with `erc7739Actions`). -- Ensure a smart account is connected; `ERC1271Caller` expects - `IERC1271.isValidSignature` at the `signer` address. -- Domain salt is omitted to match current verification; align on a salt policy - later. - -## Deploy & Try - -- This demo currently works only on Anvil (local EVM). - -- Option A: Deploy only the `ERC1271Caller` used by the demo (recommended for - this demo): - -```bash -anvil -nx run demo-app:deploy-erc1271-caller -``` - -- Option B (4337 dev flow on Anvil): Deploy 4337 factory + ERC1271Caller, then - run dev: - -```bash -anvil -nx run demo-app:dev:erc4337:anvil -``` - -- Start the demo app and open the Web SDK Test page: - -```bash -nx run demo-app:dev -``` - -- In the "Typed Data (ERC-7739)" section, connect, sign, and verify on-chain. - -Notes: - -- Requires a local Anvil at `http://localhost:8545`. -- Uses an Anvil test private key for deployment (configurable via - `PRIVATE_KEY`). diff --git a/examples/demo-app/project.json b/examples/demo-app/project.json index 4ac930e31..ab11f2efc 100644 --- a/examples/demo-app/project.json +++ b/examples/demo-app/project.json @@ -149,23 +149,6 @@ }, "dependsOn": ["build:local"] }, - "dev:erc4337:anvil": { - "executor": "nx:run-commands", - "options": { - "cwd": "examples/demo-app", - "commands": [ - { - "command": "pnpm nx dev auth-server", - "prefix": "Auth-Server:" - }, - { - "command": "PORT=3004 nuxt dev", - "prefix": "Demo-App:" - } - ] - }, - "dependsOn": ["deploy-contracts-erc4337-runtime"] - }, "e2e:setup": { "executor": "nx:run-commands", "options": { From e54ed12da66f48ff0e08c0714dba42823d070d33 Mon Sep 17 00:00:00 2001 From: cbe Date: Wed, 19 Nov 2025 11:33:46 -0800 Subject: [PATCH 5/5] fix: code review comments minor cleanup --- .../demo-app/components/TypedDataErc7739.vue | 2 + examples/demo-app/tests/web-sdk-test.spec.ts | 11 +-- examples/demo-app/utils/erc7739.ts | 85 +++++++++++++------ 3 files changed, 67 insertions(+), 31 deletions(-) diff --git a/examples/demo-app/components/TypedDataErc7739.vue b/examples/demo-app/components/TypedDataErc7739.vue index f9bd96abd..dd7c09b74 100644 --- a/examples/demo-app/components/TypedDataErc7739.vue +++ b/examples/demo-app/components/TypedDataErc7739.vue @@ -117,6 +117,8 @@ const hasErc1271Caller = computed(() => !!erc1271CallerAddress && erc1271CallerA // Anvil account #1 private key (same as used in deployAccount) // Address: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 +// WARNING: This private key is for local Anvil testing only. +// DO NOT use this key in production or with real funds. const ANVIL_ACCOUNT_1_PRIVATE_KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" as Hex; const eoaAccount = privateKeyToAccount(ANVIL_ACCOUNT_1_PRIVATE_KEY); diff --git a/examples/demo-app/tests/web-sdk-test.spec.ts b/examples/demo-app/tests/web-sdk-test.spec.ts index 19a9afe21..8e6c2e36b 100644 --- a/examples/demo-app/tests/web-sdk-test.spec.ts +++ b/examples/demo-app/tests/web-sdk-test.spec.ts @@ -658,24 +658,21 @@ test("Sign and verify ERC-7739 typed data via ERC-1271", async ({ page }) => { await expect(typedDataSection).toBeVisible({ timeout: 10000 }); // Sign the ERC-7739 typed data (no connection needed with new implementation) - const tdSection = page.getByTestId("typed-data-section"); - await expect(tdSection).toBeVisible({ timeout: 15000 }); + await expect(typedDataSection).toBeVisible({ timeout: 15000 }); // Ensure ERC1271 caller address is present before interacting (ensures readiness) - const callerAddr = tdSection.getByTestId("erc1271-caller-address"); + const callerAddr = typedDataSection.getByTestId("erc1271-caller-address"); await expect(callerAddr).toBeVisible({ timeout: 30000 }); - // Small settle wait to allow client-side hydration to render actions - await page.waitForTimeout(250); // Use E2E bridge to trigger sign to avoid internal gating const signBridge = page.getByTestId("typeddata-sign-bridge"); await expect(signBridge).toBeVisible({ timeout: 20000 }); await signBridge.click(); - await expect(tdSection.getByTestId("typeddata-signature")).toBeVisible({ timeout: 20000 }); + await expect(typedDataSection.getByTestId("typeddata-signature")).toBeVisible({ timeout: 20000 }); // Verify on-chain via ERC-1271 helper contract const verifyBridge = page.getByTestId("typeddata-verify-bridge"); await expect(verifyBridge).toBeVisible({ timeout: 20000 }); await verifyBridge.click(); - const result = tdSection.getByTestId("typeddata-verify-result"); + const result = typedDataSection.getByTestId("typeddata-verify-result"); await expect(result).toBeVisible({ timeout: 30000 }); await expect(result).toContainText("Valid"); diff --git a/examples/demo-app/utils/erc7739.ts b/examples/demo-app/utils/erc7739.ts index 5a2ca0313..74c09aff2 100644 --- a/examples/demo-app/utils/erc7739.ts +++ b/examples/demo-app/utils/erc7739.ts @@ -23,6 +23,43 @@ export interface HashTypedDataErc7739Args { chainId?: number; } +/** + * Creates the verifier domain for ERC-7739 typed data wrapping. + * This domain is used to wrap application-specific typed data with account verification metadata. + * + * @param smartAccountAddress - The smart account address that will verify the signature + * @param chainId - The chain ID for the verifier domain (defaults to 1337 for local Anvil) + * @returns The TypedDataDomain for the verifier (smart account) with all required fields + */ +function createVerifierDomain(smartAccountAddress: Hex, chainId = 1337) { + return { + chainId, + name: "zksync-sso-1271" as const, + version: "1.0.0" as const, + verifyingContract: smartAccountAddress, + salt: pad("0x", { size: 32 }), + } as const; +} + +/** + * Signs EIP-712 typed data using ERC-7739 nested typed data wrapping. + * + * This function implements the ERC-7739 standard for smart account typed data signatures: + * 1. Wraps the application's typed data in a verifier domain (smart account context) + * 2. Computes the ERC-7739 hash (nested EIP-712 hash) + * 3. Signs the hash with the provided signer + * 4. Optionally prepends a validator address (required for ModularSmartAccount) + * 5. Wraps the signature with ERC-7739 metadata (domain, contents, description) + * + * The resulting signature can be verified on-chain via ERC-1271's isValidSignature. + * + * @param typedData - The application's EIP-712 typed data to sign + * @param smartAccountAddress - The smart account that will verify this signature + * @param signer - Async function that signs a hash and returns a signature (e.g., EOA.sign) + * @param chainId - Chain ID for the verifier domain (defaults to 1337) + * @param validatorAddress - Optional validator module address to prepend before wrapping (required for modular accounts) + * @returns The ERC-7739 wrapped signature with validator prefix and metadata + */ export async function signTypedDataErc7739({ typedData, smartAccountAddress, @@ -30,19 +67,11 @@ export async function signTypedDataErc7739({ chainId = 1337, validatorAddress, }: SignTypedDataErc7739Args): Promise { - const verifierDomain: TypedDataDomain = { - chainId, - name: "zksync-sso-1271", - version: "1.0.0", - verifyingContract: smartAccountAddress, - salt: pad("0x", { size: 32 }), - }; + const verifierDomain = createVerifierDomain(smartAccountAddress, chainId); - const erc7739Data: Erc7739TypedData = { - domain: verifierDomain, - types: typedData.types, - primaryType: typedData.primaryType, - message: typedData.message, + const erc7739Data = { + ...typedData, + verifierDomain, }; const hash = hashTypedData(erc7739Data); const signature = await signer(hash); @@ -60,24 +89,32 @@ export async function signTypedDataErc7739({ }); } +/** + * Computes the ERC-7739 hash for typed data without signing. + * + * This function is useful for: + * - Debugging signature verification issues by comparing JS and on-chain hashes + * - Precomputing the digest that will be signed + * - Testing hash computation without requiring a signer + * + * The hash is computed by wrapping the application's typed data in a verifier domain + * (smart account context) and computing the nested EIP-712 hash per ERC-7739. + * + * @param typedData - The application's EIP-712 typed data + * @param smartAccountAddress - The smart account address used in the verifier domain + * @param chainId - Chain ID for the verifier domain (defaults to 1337) + * @returns The ERC-7739 hash (nested EIP-712 hash) that would be signed + */ export function hashTypedDataErc7739({ typedData, smartAccountAddress, chainId = 1337, }: HashTypedDataErc7739Args): Hex { - const verifierDomain: TypedDataDomain = { - chainId, - name: "zksync-sso-1271", - version: "1.0.0", - verifyingContract: smartAccountAddress, - salt: pad("0x", { size: 32 }), - }; + const verifierDomain = createVerifierDomain(smartAccountAddress, chainId); - const erc7739Data: Erc7739TypedData = { - domain: verifierDomain, - types: typedData.types, - primaryType: typedData.primaryType, - message: typedData.message, + const erc7739Data = { + ...typedData, + verifierDomain, }; return hashTypedData(erc7739Data); }