diff --git a/packages/auth-server/pages/recovery.disabled/guardian/(actions)/confirm-guardian.vue b/packages/auth-server/pages/recovery.disabled/guardian/(actions)/confirm-guardian.vue
deleted file mode 100644
index 00f0c4cac..000000000
--- a/packages/auth-server/pages/recovery.disabled/guardian/(actions)/confirm-guardian.vue
+++ /dev/null
@@ -1,209 +0,0 @@
-
-
-
-
-
-
- Confirm Guardian Account
-
-
- Review and confirm the guardian details below:
-
-
-
- {{ accountAddress }}
-
-
-
-
- {{ guardianAddress }}
-
-
-
- {{ isSsoAccount === null ? "Checking account type..." : (isSsoAccount ? "ZKsync SSO Account" : "Standard Account") }}
-
-
-
-
-
-
-
- Checking guardian account...
-
-
-
-
-
- An error occurred while checking the guardian account. Please try again.
-
-
-
- This guardian has been successfully confirmed and can now help recover your account if needed.
-
-
-
-
- {{ status.message }}
-
-
-
-
-
- Confirm Guardian
-
-
-
- {{ confirmGuardianError }}
-
-
-
-
-
-
diff --git a/packages/auth-server/pages/recovery.disabled/account-not-ready.vue b/packages/auth-server/pages/recovery/account-not-ready.vue
similarity index 100%
rename from packages/auth-server/pages/recovery.disabled/account-not-ready.vue
rename to packages/auth-server/pages/recovery/account-not-ready.vue
diff --git a/packages/auth-server/pages/recovery.disabled/google/index.vue b/packages/auth-server/pages/recovery/google/index.vue
similarity index 88%
rename from packages/auth-server/pages/recovery.disabled/google/index.vue
rename to packages/auth-server/pages/recovery/google/index.vue
index d72cd019b..ea39b8611 100644
--- a/packages/auth-server/pages/recovery.disabled/google/index.vue
+++ b/packages/auth-server/pages/recovery/google/index.vue
@@ -12,7 +12,8 @@
diff --git a/packages/auth-server/pages/recovery.disabled/guardian/(actions)/confirm-recovery.vue b/packages/auth-server/pages/recovery/guardian/(actions)/confirm-recovery.vue
similarity index 70%
rename from packages/auth-server/pages/recovery.disabled/guardian/(actions)/confirm-recovery.vue
rename to packages/auth-server/pages/recovery/guardian/(actions)/confirm-recovery.vue
index 4c80adbe8..dd25a0a1f 100644
--- a/packages/auth-server/pages/recovery.disabled/guardian/(actions)/confirm-recovery.vue
+++ b/packages/auth-server/pages/recovery/guardian/(actions)/confirm-recovery.vue
@@ -75,12 +75,12 @@
>
Confirm Recovery
-
+ />
import { useAppKitAccount } from "@reown/appkit/vue";
-import { type Address, hexToBytes, isAddressEqual, keccak256, toHex } from "viem";
-// import { base64UrlToUint8Array } from "zksync-sso/utils";
+import { type Address, encodeAbiParameters, isAddressEqual, keccak256, pad, parseAbiParameters, toHex } from "viem";
+import { base64urlToUint8Array, getPasskeySignatureFromPublicKeyBytes, getPublicKeyBytesFromPasskeySignature } from "zksync-sso-4337/utils";
import { z } from "zod";
import { uint8ArrayToHex } from "@/utils/formatters";
@@ -132,10 +132,15 @@ const RecoveryParamsSchema = z
})
.refine(
async (data) => {
- const dataToHash = `${data.accountAddress}:${data.credentialId}:${data.credentialPublicKey}`;
+ // Calculate checksum
+ // Normalize accountAddress to lowercase for consistent hashing
+ const normalizedAddress = data.accountAddress.toLowerCase();
+ const dataToHash = `${normalizedAddress}:${data.credentialId}:${data.credentialPublicKey}`;
+
const calculatedChecksum = uint8ArrayToHex(
new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(dataToHash))).slice(0, 8),
);
+
return calculatedChecksum === data.checksum;
},
{
@@ -144,6 +149,7 @@ const RecoveryParamsSchema = z
);
const generalError = ref(null);
+const recoveryCheckTrigger = ref(0);
const isLoadingGuardians = ref(false);
const loadingGuardiansError = ref(null);
@@ -166,9 +172,41 @@ const recoveryParams = computedAsync(async () => RecoveryParamsSchema.parseAsync
}));
const recoveryCompleted = computedAsync(async () => {
- if (!recoveryParams.value?.accountAddress) return false;
+ // Force re-evaluation when trigger changes
+ const _triggerValue = recoveryCheckTrigger.value;
+
+ // eslint-disable-next-line no-console
+ console.log("recoveryCompleted evaluating, trigger:", _triggerValue);
+
+ if (!recoveryParams.value?.accountAddress || !recoveryParams.value?.credentialId || !recoveryParams.value?.credentialPublicKey) {
+ return false;
+ }
+
const result = await getRecovery(recoveryParams.value.accountAddress);
- return result?.hashedCredentialId === keccak256(toHex(base64UrlToUint8Array(recoveryParams.value.credentialId)));
+
+ // The smart contract stores keccak256(data) where data is the encoded recovery payload
+ // We need to reconstruct the same data structure that was passed to initializeRecovery
+ const parsedPublicKey = JSON.parse(recoveryParams.value.credentialPublicKey);
+ const credentialPublicKeyBytes = getPasskeySignatureFromPublicKeyBytes([parsedPublicKey.x, parsedPublicKey.y]);
+ const publicKeyBytes = getPublicKeyBytesFromPasskeySignature(credentialPublicKeyBytes);
+ const publicKeyHex = [
+ pad(toHex(publicKeyBytes[0]), { size: 32 }),
+ pad(toHex(publicKeyBytes[1]), { size: 32 }),
+ ] as const;
+
+ const recoveryData = encodeAbiParameters(
+ parseAbiParameters("bytes32 credentialIdHash, bytes32[2] publicKey"),
+ [
+ keccak256(toHex(base64urlToUint8Array(recoveryParams.value.credentialId))),
+ publicKeyHex,
+ ],
+ );
+
+ const expectedHashedData = keccak256(recoveryData);
+
+ const isComplete = result?.hashedData === expectedHashedData;
+
+ return isComplete;
});
const guardians = computedAsync(async () => {
@@ -215,13 +253,41 @@ const handleConfirmRecovery = async () => {
if (!recoveryParams.value) return;
+ // Parse the credentialPublicKey JSON string to get x,y coordinates
+ const parsedPublicKey = JSON.parse(recoveryParams.value.credentialPublicKey);
+ // Convert coordinates to proper COSE format expected by initRecovery
+ const credentialPublicKeyBytes = getPasskeySignatureFromPublicKeyBytes([parsedPublicKey.x, parsedPublicKey.y]);
+
await initRecovery({
client,
accountToRecover: recoveryParams.value.accountAddress,
- credentialPublicKey: hexToBytes(`0x${recoveryParams.value.credentialPublicKey}`),
- accountId: recoveryParams.value.credentialId,
+ credentialPublicKey: credentialPublicKeyBytes,
+ credentialId: recoveryParams.value.credentialId,
});
confirmGuardianErrorMessage.value = null;
+
+ // Poll for recovery completion instead of waiting once
+ // The contract state might take a few seconds to update after transaction confirmation
+ const maxRetries = 10;
+ const retryDelay = 1000; // 1 second between checks
+
+ for (let i = 0; i < maxRetries; i++) {
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
+ recoveryCheckTrigger.value++;
+
+ // Wait a bit for computedAsync to evaluate
+ await new Promise((resolve) => setTimeout(resolve, 500));
+
+ // eslint-disable-next-line no-console
+ console.log(`Polling attempt ${i + 1}/${maxRetries}: recoveryCompleted =`, recoveryCompleted.value);
+
+ // Check if recovery is complete
+ if (recoveryCompleted.value === true) {
+ // eslint-disable-next-line no-console
+ console.log("Recovery confirmed as complete!");
+ break;
+ }
+ }
} catch (err) {
confirmGuardianErrorMessage.value = "An error occurred while confirming the guardian. Please try again.";
// eslint-disable-next-line no-console
diff --git a/packages/auth-server/pages/recovery.disabled/guardian/find-account.vue b/packages/auth-server/pages/recovery/guardian/find-account.vue
similarity index 100%
rename from packages/auth-server/pages/recovery.disabled/guardian/find-account.vue
rename to packages/auth-server/pages/recovery/guardian/find-account.vue
diff --git a/packages/auth-server/pages/recovery.disabled/guardian/index.vue b/packages/auth-server/pages/recovery/guardian/index.vue
similarity index 100%
rename from packages/auth-server/pages/recovery.disabled/guardian/index.vue
rename to packages/auth-server/pages/recovery/guardian/index.vue
diff --git a/packages/auth-server/pages/recovery.disabled/guardian/unknown-account.vue b/packages/auth-server/pages/recovery/guardian/unknown-account.vue
similarity index 100%
rename from packages/auth-server/pages/recovery.disabled/guardian/unknown-account.vue
rename to packages/auth-server/pages/recovery/guardian/unknown-account.vue
diff --git a/packages/auth-server/pages/recovery.disabled/index.vue b/packages/auth-server/pages/recovery/index.vue
similarity index 93%
rename from packages/auth-server/pages/recovery.disabled/index.vue
rename to packages/auth-server/pages/recovery/index.vue
index d88108aaf..d0f128147 100644
--- a/packages/auth-server/pages/recovery.disabled/index.vue
+++ b/packages/auth-server/pages/recovery/index.vue
@@ -46,5 +46,6 @@
diff --git a/packages/auth-server/playwright.config.ts b/packages/auth-server/playwright.config.ts
new file mode 100644
index 000000000..59225fa08
--- /dev/null
+++ b/packages/auth-server/playwright.config.ts
@@ -0,0 +1,45 @@
+import { defineConfig, devices } from "@playwright/test";
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+export default defineConfig({
+ testDir: "./tests",
+ fullyParallel: false,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: 1,
+ reporter: "html",
+ use: {
+ baseURL: "http://localhost:3002",
+ trace: "on-first-retry",
+ video: "retain-on-failure",
+ },
+
+ projects: [
+ {
+ name: "chromium",
+ use: { ...devices["Desktop Chrome"] },
+ },
+ ],
+
+ webServer: [
+ {
+ command: "PORT=3004 pnpm nx dev auth-server-api",
+ url: "http://localhost:3004/api/health",
+ reuseExistingServer: !process.env.CI,
+ stdout: "pipe",
+ stderr: "pipe",
+ timeout: 180_000,
+ },
+ {
+ command:
+ "NUXT_PUBLIC_AUTH_SERVER_API_URL=http://localhost:3004 PORT=3002 pnpm nx dev:no-deploy auth-server",
+ url: "http://localhost:3002",
+ reuseExistingServer: !process.env.CI,
+ stdout: "pipe",
+ stderr: "pipe",
+ timeout: 180_000,
+ },
+ ],
+});
diff --git a/packages/auth-server/project.json b/packages/auth-server/project.json
index 6b72b60d5..73f313017 100644
--- a/packages/auth-server/project.json
+++ b/packages/auth-server/project.json
@@ -93,6 +93,14 @@
"command": "firebase hosting:channel:deploy --only zksync-auth-server-staging --project zksync-auth-server-staging"
},
"dependsOn": ["build"]
+ },
+ "e2e:guardian": {
+ "executor": "nx:run-commands",
+ "options": {
+ "cwd": "packages/auth-server",
+ "command": "PW_TEST_HTML_REPORT_OPEN=never pnpm exec playwright test guardian.spec.ts --config playwright.config.ts"
+ },
+ "dependsOn": ["build:local"]
}
}
}
diff --git a/packages/auth-server/stores/client.ts b/packages/auth-server/stores/client.ts
index 5070aaac6..c0e0894c7 100644
--- a/packages/auth-server/stores/client.ts
+++ b/packages/auth-server/stores/client.ts
@@ -5,9 +5,6 @@ import { /* generatePrivateKey, */ generatePrivateKey, privateKeyToAccount } fro
import { localhost } from "viem/chains";
import { createPasskeyClient } from "zksync-sso-4337/client";
-// TODO: OIDC and guardian recovery are not yet available in sdk-4337
-// import { createZkSyncOidcClient, type ZkSyncSsoClient } from "zksync-sso/client/oidc";
-// import { createZksyncRecoveryGuardianClient } from "zksync-sso/client/recovery";
import localChainData from "./local-node.json";
const zksyncOsTestnet = defineChain({
@@ -66,6 +63,9 @@ type ChainContracts = {
bundlerUrl?: string;
beacon?: Address; // Optional, for deployment
testPaymaster?: Address; // Optional, for paymaster sponsorship
+ recovery?: Address; // Recovery module (legacy SDK)
+ guardianExecutor?: Address; // Guardian executor module (ERC-4337)
+ accountPaymaster?: Address; // Paymaster for account operations
};
export const contractsByChain: Record = {
@@ -179,22 +179,6 @@ export const useClientStore = defineStore("client", () => {
return client;
};
- // TODO: Guardian recovery not yet available in sdk-4337
- // const getRecoveryClient = ({ chainId, address }: { chainId: SupportedChainId; address: Address }) => {
- // const chain = supportedChains.find((chain) => chain.id === chainId);
- // if (!chain) throw new Error(`Chain with id ${chainId} is not supported`);
- // const contracts = contractsByChain[chainId];
- //
- // const client = createZksyncRecoveryGuardianClient({
- // address,
- // contracts,
- // chain: chain,
- // transport: createTransport(),
- // });
- //
- // return client;
- // };
-
// TODO: OIDC client not yet available in sdk-4337
// const getOidcClient = ({ chainId, address }: { chainId: SupportedChainId; address: Address }): ZkSyncSsoClient => {
// const chain = supportedChains.find((chain) => chain.id === chainId);
@@ -287,8 +271,8 @@ export const useClientStore = defineStore("client", () => {
getClient,
getThrowAwayClient,
getWalletClient,
- // getRecoveryClient, // TODO: Not available in sdk-4337
getConfigurableClient,
+ contractsByChain,
// getOidcClient, // TODO: Not available in sdk-4337
};
});
diff --git a/packages/auth-server/stores/local-node.json b/packages/auth-server/stores/local-node.json
index 51f017a2a..90a9aaba8 100644
--- a/packages/auth-server/stores/local-node.json
+++ b/packages/auth-server/stores/local-node.json
@@ -2,15 +2,15 @@
"rpcUrl": "http://localhost:8545",
"chainId": 1337,
"deployer": "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720",
- "eoaValidator": "0x7079ade5d4C71aE7868E6AC8553148FfC3D8d660",
- "sessionValidator": "0xE619369f26285FEd402bd0c3526aEb1faf2BE2C0",
- "webauthnValidator": "0x85ed36BD17265a4eb2f6d87FAd3E6277066986f0",
- "guardianExecutor": "0xb3b3496CC339d80b2182a3985FeF5EBE60bc896C",
- "accountImplementation": "0xddfa09697bCe57712B5365ff573c3e6b3C8FFDDb",
- "beacon": "0x197072f17a5e1A35C1909999e6f3cFEDe5A42BB8",
- "factory": "0xF9e4C9Cfec57860d82e6B41Cd50DEF2b3826D5CF",
- "testPaymaster": "0xf8C803b1378C96557381f43F86f77D72D79BeE95",
- "mockPaymaster": "0xf8C803b1378C96557381f43F86f77D72D79BeE95",
+ "eoaValidator": "0xEc5AB17Cc35221cdF54EaEb0868eA82d4D75D9Bf",
+ "sessionValidator": "0xd512108c249cC5ec5370491AD916Be31bb88Dad2",
+ "webauthnValidator": "0xadF4aCF92F0A1398d50402Db551feb92b1125DAb",
+ "guardianExecutor": "0x0efdDbB35e9BBc8c762E5B4a0f627210b6c9A721",
+ "accountImplementation": "0xd7400e73bA36388c71C850aC96288E390bd22Ebe",
+ "beacon": "0xbe38809914b552f295cD3e8dF2e77b3DA69cBC8b",
+ "factory": "0xb11632A56608Bad85562FaE6f1AF269d1fE9e8e6",
+ "testPaymaster": "0x287E8b17ce85B451a906EA8A179212e5a24680A3",
+ "mockPaymaster": "0x287E8b17ce85B451a906EA8A179212e5a24680A3",
"entryPoint": "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108",
"bundlerUrl": "http://localhost:4337"
}
diff --git a/packages/auth-server/tests/guardian.spec.ts b/packages/auth-server/tests/guardian.spec.ts
new file mode 100644
index 000000000..278759aa8
--- /dev/null
+++ b/packages/auth-server/tests/guardian.spec.ts
@@ -0,0 +1,1027 @@
+/* eslint-disable no-console, simple-import-sort/imports */
+import { exec } from "child_process";
+import { promisify } from "util";
+
+import { expect, test } from "@playwright/test";
+import type { Page } from "@playwright/test";
+
+const execAsync = promisify(exec);
+
+/**
+ * Fund an account with ETH using Anvil's default rich wallet
+ */
+async function fundAccount(address: string, amount: string = "1"): Promise {
+ const ANVIL_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
+ const cmd = `cast send ${address} --value ${amount}ether --private-key ${ANVIL_PRIVATE_KEY} --rpc-url http://localhost:8545`;
+ try {
+ await execAsync(cmd);
+ console.log(`ā
Funded ${address} with ${amount} ETH`);
+ } catch (error) {
+ console.error(`ā Failed to fund ${address}:`, error);
+ throw error;
+ }
+}
+
+type WebAuthnCredential = {
+ credentialId: string;
+ isResidentCredential: boolean;
+ privateKey: string;
+ signCount: number;
+};
+
+async function waitForAuthServerToLoad(page: Page): Promise {
+ const maxRetryAttempts = 10;
+ let retryCount = 0;
+
+ // Wait for auth server to finish loading
+ await page.goto("http://localhost:3002");
+ let authServerHeader = page.getByTestId("signup");
+ while (!(await authServerHeader.isVisible()) && retryCount < maxRetryAttempts) {
+ await page.waitForTimeout(1000);
+ authServerHeader = page.getByTestId("signup");
+ retryCount++;
+
+ console.log(`Waiting for auth server to load (retry ${retryCount})...`);
+ }
+ console.log("Auth Server loaded");
+}
+
+async function setupWebAuthn(page: Page): Promise<{
+ authenticatorId: string;
+ newCredential: WebAuthnCredential | null;
+ client: unknown;
+}> {
+ const client = await page.context().newCDPSession(page);
+ await client.send("WebAuthn.enable");
+
+ let newCredential: WebAuthnCredential | null = null;
+ client.on("WebAuthn.credentialAdded", (credentialAdded) => {
+ newCredential = credentialAdded.credential as WebAuthnCredential;
+ console.log(`Credential added: ${newCredential.credentialId}`);
+ });
+
+ const result = await client.send("WebAuthn.addVirtualAuthenticator", {
+ options: {
+ protocol: "ctap2",
+ transport: "usb",
+ hasResidentKey: true,
+ hasUserVerification: true,
+ isUserVerified: true,
+ automaticPresenceSimulation: true,
+ },
+ });
+
+ return { authenticatorId: result.authenticatorId, newCredential, client };
+}
+
+async function reuseCredential(page: Page, credential: WebAuthnCredential): Promise {
+ const client = await page.context().newCDPSession(page);
+ await client.send("WebAuthn.enable");
+
+ const result = await client.send("WebAuthn.addVirtualAuthenticator", {
+ options: {
+ protocol: "ctap2",
+ transport: "usb",
+ hasResidentKey: true,
+ hasUserVerification: true,
+ isUserVerified: true,
+ automaticPresenceSimulation: true,
+ },
+ });
+
+ await client.send("WebAuthn.addCredential", {
+ authenticatorId: result.authenticatorId,
+ credential: credential,
+ });
+}
+
+test.beforeEach(async ({ page }) => {
+ page.on("console", (msg) => {
+ if (msg.type() === "error") console.log(`Page error: "${msg.text()}"`);
+ });
+ page.on("pageerror", (exception) => {
+ console.log(`Page uncaught exception: "${exception}"`);
+ });
+
+ await waitForAuthServerToLoad(page);
+});
+
+test("Guardian flow: propose and confirm guardian", async ({ page, context: _context }) => {
+ test.setTimeout(90000); // Extended timeout for full guardian flow with account creation
+ console.log("\n=== Starting Guardian E2E Test ===\n");
+
+ // ===== Step 1: Create Primary Account =====
+ console.log("Step 1: Creating primary account...");
+ await page.goto("http://localhost:3002");
+ await page.waitForTimeout(1000);
+
+ // No popup - we're already on the auth-server signup page
+ await expect(page.getByTestId("signup")).toBeVisible();
+ page.on("console", (msg) => {
+ if (msg.type() === "error") console.log(`Page error: "${msg.text()}"`);
+ });
+ page.on("pageerror", (exception) => {
+ console.log(`Page exception: "${exception}"`);
+ });
+
+ const { newCredential: _primaryCredential } = await setupWebAuthn(page);
+
+ await page.getByTestId("signup").click();
+
+ // Wait for navigation to dashboard after signup
+ try {
+ await page.waitForURL("**/dashboard", { timeout: 15000 });
+ } catch (e) {
+ console.log(`Navigation timeout. Current URL: ${page.url()}`);
+ // Check for error messages on the page
+ const errorElement = page.locator("[role=\"alert\"], .error, [class*=\"error\"]").first();
+ if (await errorElement.isVisible({ timeout: 1000 }).catch(() => false)) {
+ const errorText = await errorElement.textContent();
+ console.log(`Error message on page: ${errorText}`);
+ }
+ throw e;
+ }
+ await page.waitForTimeout(2000);
+
+ // Wait for account address to be visible
+ await expect(page.getByTestId("account-address")).toBeVisible({ timeout: 10000 });
+ const primaryAddressText = await page.getByTestId("account-address").getAttribute("data-address") || "";
+ console.log(`Primary account created: ${primaryAddressText}`);
+
+ // Fund the primary account with ETH
+ await fundAccount(primaryAddressText, "1");
+
+ // ===== Step 2: Navigate to Guardian Settings ===
+ console.log("\nStep 2: Navigating to guardian settings...");
+ await page.goto("http://localhost:3002/dashboard/settings");
+ await page.waitForTimeout(2000);
+
+ // Find the "Add Recovery Method" button
+ const addRecoveryButton = page.getByTestId("add-recovery-method");
+ await expect(addRecoveryButton).toBeVisible({ timeout: 10000 });
+ await addRecoveryButton.click();
+ await page.waitForTimeout(1000);
+
+ // Select "Recover with Guardian" option
+ const guardianMethodButton = page.getByTestId("add-guardian-method");
+ await expect(guardianMethodButton).toBeVisible({ timeout: 5000 });
+ await guardianMethodButton.click();
+ await page.waitForTimeout(1000);
+
+ const continueButton = page.getByTestId("continue-recovery-method");
+ await expect(continueButton).toBeVisible({ timeout: 10000 });
+ await continueButton.click();
+ await page.waitForTimeout(1000);
+
+ // ===== Step 3: Create Guardian Account in Incognito =====
+ console.log("\nStep 3: Creating guardian account in new context...");
+ const guardianContext = await page.context().browser()!.newContext();
+ const guardianPage = await guardianContext.newPage();
+
+ guardianPage.on("console", (msg) => {
+ if (msg.type() === "error") console.log(`Guardian page error: "${msg.text()}"`);
+ });
+ guardianPage.on("pageerror", (exception) => {
+ console.log(`Guardian page exception: "${exception}"`);
+ });
+
+ await guardianPage.goto("http://localhost:3002");
+ await guardianPage.waitForTimeout(2000);
+
+ // No popup - we're already on the auth-server signup page
+ const { newCredential: guardianCredential } = await setupWebAuthn(guardianPage);
+
+ await guardianPage.getByTestId("signup").click();
+
+ // Wait for navigation to dashboard after signup
+ try {
+ await guardianPage.waitForURL("**/dashboard", { timeout: 15000 });
+ } catch (e) {
+ console.log(`Guardian navigation timeout. Current URL: ${guardianPage.url()}`);
+ // Check for error messages on the page
+ const errorElement = guardianPage.locator("[role=\"alert\"], .error, [class*=\"error\"]").first();
+ if (await errorElement.isVisible({ timeout: 1000 }).catch(() => false)) {
+ const errorText = await errorElement.textContent();
+ console.log(`Guardian error message: ${errorText}`);
+ }
+ throw e;
+ }
+ await guardianPage.waitForTimeout(2000);
+
+ // Wait for account address to be visible
+ await expect(guardianPage.getByTestId("account-address")).toBeVisible({ timeout: 10000 });
+ const guardianAddressText = await guardianPage.getByTestId("account-address").getAttribute("data-address") || "";
+ console.log(`Guardian account created: ${guardianAddressText}`);
+
+ // Fund the guardian account with ETH
+ await fundAccount(guardianAddressText, "1");
+
+ // ===== Step 4: Propose Guardian ===
+ console.log("\nStep 4: Proposing guardian...");
+ await page.bringToFront();
+
+ // Capture console logs from the primary page during proposal
+ const primaryPageConsole: string[] = [];
+ page.on("console", (msg) => {
+ const logMsg = `[${msg.type()}] ${msg.text()}`;
+ primaryPageConsole.push(logMsg);
+ console.log(`Primary page console: ${logMsg}`);
+ });
+
+ // Enter guardian address in the modal/input
+ const guardianInput = page.getByTestId("guardian-address-input").locator("input");
+ await expect(guardianInput).toBeVisible({ timeout: 5000 });
+ await guardianInput.fill(guardianAddressText);
+
+ // Click Propose button
+ const proposeButton = page.getByRole("button", { name: /Propose/i, exact: false });
+ await proposeButton.click();
+
+ // Wait for guardian proposal to complete
+ // NOTE: The SSO client automatically signs ERC-4337 transactions,
+ // so there are NO popup windows to interact with during guardian proposal
+ console.log("Waiting for guardian proposal transaction to complete...");
+
+ // Check for errors during proposal
+ const errorMessage = page.locator("text=/error.*proposing/i");
+ const errorVisible = await errorMessage.isVisible({ timeout: 8000 }).catch(() => false);
+ if (errorVisible) {
+ const errorText = await errorMessage.textContent();
+ throw new Error(`Guardian proposal failed: ${errorText}`);
+ }
+
+ await page.waitForTimeout(8000); // Wait for module installation + guardian proposal
+ console.log("Guardian proposal initiated");
+
+ // Log all captured console messages
+ if (primaryPageConsole.length > 0) {
+ console.log(`\nš Captured ${primaryPageConsole.length} console messages from primary page:`);
+ primaryPageConsole.forEach((msg, i) => console.log(` ${i + 1}. ${msg}`));
+ }
+
+ // Handle "Do you wish to confirm your guardian now?" dialog
+ try {
+ const confirmLaterButton = page.getByRole("button", { name: /Confirm Later/i });
+ await confirmLaterButton.waitFor({ state: "visible", timeout: 10000 });
+ await confirmLaterButton.click();
+ console.log("Clicked 'Confirm Later' on the confirmation prompt");
+ await page.waitForTimeout(5000); // Wait for dialog to close and transaction to complete
+ } catch (error) {
+ console.log("ā Warning: 'Do you wish to confirm your guardian now?' prompt not found");
+ console.log(` ${error instanceof Error ? error.message : String(error)}`);
+ }
+
+ // ===== Step 5: Confirm Guardian =====
+ console.log("\nStep 5: Confirming guardian...");
+
+ // Construct confirmation URL
+ const confirmUrl = `http://localhost:3002/recovery/guardian/confirm-guardian?accountAddress=${primaryAddressText}&guardianAddress=${guardianAddressText}`;
+ console.log(`Confirmation URL: ${confirmUrl}`);
+
+ // Capture page errors
+ const pageErrors: string[] = [];
+ guardianPage.on("pageerror", (error) => {
+ const errorMsg = `Page error: ${error.message}`;
+ console.error(errorMsg);
+ pageErrors.push(errorMsg);
+ });
+
+ // Capture console errors
+ guardianPage.on("console", (msg) => {
+ if (msg.type() === "error") {
+ console.error(`Console error: ${msg.text()}`);
+ }
+ });
+
+ await guardianPage.goto(confirmUrl);
+ console.log("Page loaded, waiting for content...");
+ await guardianPage.waitForTimeout(2000);
+
+ // Debug: Log page URL and title
+ console.log(`Current URL: ${guardianPage.url()}`);
+ console.log(`Page title: ${await guardianPage.title()}`);
+
+ // Debug: Take screenshot
+ await guardianPage.screenshot({ path: "test-results/confirm-guardian-debug.png" });
+ console.log("Screenshot saved to test-results/confirm-guardian-debug.png");
+
+ // Debug: Check for any visible buttons
+ const allButtons = await guardianPage.locator("button").all();
+ console.log(`\nFound ${allButtons.length} button elements on page`);
+ for (let i = 0; i < allButtons.length; i++) {
+ const isVisible = await allButtons[i].isVisible().catch(() => false);
+ const text = await allButtons[i].textContent().catch(() => "N/A");
+ console.log(` Button ${i + 1}: visible=${isVisible}, text="${text}"`);
+ }
+
+ // Debug: Check page content
+ const bodyText = await guardianPage.locator("body").textContent();
+ console.log(`\nPage body text (first 500 chars): ${bodyText?.substring(0, 500)}`);
+
+ // Debug: Check for error messages on page
+ const errorElements = await guardianPage.locator("[class*=\"error\"], [class*=\"Error\"]").all();
+ if (errorElements.length > 0) {
+ console.log(`\nFound ${errorElements.length} error elements:`);
+ for (const elem of errorElements) {
+ const text = await elem.textContent();
+ console.log(` Error element: ${text}`);
+ }
+ }
+
+ // Debug: Log any accumulated page errors
+ if (pageErrors.length > 0) {
+ console.log(`\nAccumulated page errors (${pageErrors.length}):`);
+ pageErrors.forEach((err, i) => console.log(` ${i + 1}. ${err}`));
+ }
+
+ // Check if we need to sign in or if already logged in as guardian
+ console.log("\nChecking authentication state...");
+ const confirmGuardianButton = guardianPage.getByRole("button", { name: /Confirm Guardian/i });
+ const signInSsoButton = guardianPage.getByTestId("sign-in-sso");
+ const isSignInVisible = await signInSsoButton.isVisible({ timeout: 2000 }).catch(() => false);
+
+ if (isSignInVisible) {
+ // Need to sign in
+ console.log("Sign in required, clicking sign-in button...");
+ await signInSsoButton.click();
+
+ // Wait for redirect to login page
+ await guardianPage.waitForTimeout(2000);
+
+ // Should be redirected to login/signup page - use existing credential
+ await reuseCredential(guardianPage, guardianCredential!);
+
+ // Click signin button
+ const signInButton = guardianPage.getByTestId("signin");
+ if (await signInButton.isVisible({ timeout: 2000 }).catch(() => false)) {
+ await signInButton.click();
+ // Wait for redirect back to confirmation page
+ await guardianPage.waitForURL("**/confirm-guardian**", { timeout: 10000 });
+ await guardianPage.waitForTimeout(2000);
+ }
+ } else {
+ // Already logged in as guardian
+ console.log("Already logged in as guardian SSO account, proceeding to confirm...");
+ }
+
+ // Now confirm guardian should be visible
+ console.log("\nLooking for 'Confirm Guardian' button...");
+ const buttonCount = await confirmGuardianButton.count();
+ console.log(`Found ${buttonCount} matching button(s)`);
+
+ await expect(confirmGuardianButton).toBeVisible({ timeout: 10000 });
+
+ console.log("Clicking Confirm Guardian button...");
+ await confirmGuardianButton.click();
+
+ // Wait for transaction
+ await guardianPage.waitForTimeout(3000);
+
+ // Check if we need to sign the confirmation
+ const guardianPagesBefore = guardianContext.pages().length;
+ await guardianPage.waitForTimeout(1000);
+ if (guardianContext.pages().length > guardianPagesBefore) {
+ const guardianSignPopup = guardianContext.pages()[guardianContext.pages().length - 1];
+ console.log("Guardian signing popup detected...");
+ await reuseCredential(guardianSignPopup, guardianCredential!);
+ const confirmBtn = guardianSignPopup.getByTestId("confirm");
+ if (await confirmBtn.isVisible({ timeout: 5000 })) {
+ await confirmBtn.click();
+ await guardianPage.waitForTimeout(3000);
+ }
+ }
+
+ console.log("Waiting for guardian confirmation to complete...");
+ await guardianPage.waitForTimeout(5000);
+
+ // Check for success message
+ const successIndicator = guardianPage.getByText(/Guardian.*confirmed|Success|Confirmed/i).first();
+ if (await successIndicator.isVisible({ timeout: 5000 })) {
+ console.log("ā
Guardian confirmation success message visible");
+ } else {
+ console.log("ā ļø No explicit success message found, but continuing...");
+ }
+
+ // ===== Step 6: Verify Guardian is Active =====
+ console.log("\nStep 6: Verifying guardian is active...");
+ await page.bringToFront();
+ await page.reload();
+ await page.waitForTimeout(3000);
+
+ // Look for the guardian in the active guardians list
+ // Use a more specific selector to avoid matching the URL in "Confirm Later" flow
+ const guardiansList = page.getByRole("main").locator(`text=${guardianAddressText.slice(0, 8)}`).first();
+ if (await guardiansList.isVisible({ timeout: 5000 })) {
+ console.log("ā
Guardian found in active guardians list");
+ } else {
+ console.log("ā ļø Guardian not visible in list yet, may need more time");
+ }
+
+ console.log("\n=== Guardian E2E Test Complete ===\n");
+
+ // Cleanup
+ await guardianContext.close();
+});
+
+test("Guardian flow: propose guardian with paymaster", async ({ page }) => {
+ test.setTimeout(90000); // Extended timeout for full guardian flow with paymaster
+ console.log("\n=== Starting Guardian with Paymaster E2E Test ===\n");
+
+ // ===== Step 1: Create Primary Account with Paymaster =====
+ console.log("Step 1: Creating primary account with paymaster...");
+ await page.goto("http://localhost:3002");
+ await page.waitForTimeout(1000);
+
+ // No popup - we're already on the auth-server signup page
+ await expect(page.getByTestId("signup")).toBeVisible();
+ page.on("console", (msg) => {
+ if (msg.type() === "error") console.log(`Page error: "${msg.text()}"`);
+ });
+
+ await setupWebAuthn(page);
+
+ await page.getByTestId("signup").click();
+
+ // Wait for navigation to dashboard after signup
+ try {
+ await page.waitForURL("**/dashboard", { timeout: 15000 });
+ } catch (e) {
+ console.log(`Navigation timeout. Current URL: ${page.url()}`);
+ // Check for error messages on the page
+ const errorElement = page.locator("[role=\"alert\"], .error, [class*=\"error\"]").first();
+ if (await errorElement.isVisible({ timeout: 1000 }).catch(() => false)) {
+ const errorText = await errorElement.textContent();
+ console.log(`Error message on page: ${errorText}`);
+ }
+ throw e;
+ }
+ await page.waitForTimeout(2000);
+
+ // Wait for account address to be visible
+ await expect(page.getByTestId("account-address")).toBeVisible({ timeout: 10000 });
+ const primaryAddressText = await page.getByTestId("account-address").getAttribute("data-address") || "";
+ console.log(`Primary account created: ${primaryAddressText}`);
+
+ // Fund the primary account with ETH
+ await fundAccount(primaryAddressText, "1");
+
+ // ===== Step 2: Create Guardian Account ===
+ console.log("\nStep 2: Creating guardian account...");
+ const guardianContext = await page.context().browser()!.newContext();
+ const guardianPage = await guardianContext.newPage();
+
+ await guardianPage.goto("http://localhost:3002");
+ await guardianPage.waitForTimeout(2000);
+
+ // No popup - we're already on the auth-server signup page
+ const { newCredential: guardianCredential } = await setupWebAuthn(guardianPage);
+
+ await guardianPage.getByTestId("signup").click();
+
+ // Wait for navigation to dashboard after signup
+ try {
+ await guardianPage.waitForURL("**/dashboard", { timeout: 15000 });
+ } catch (e) {
+ console.log(`Guardian navigation timeout. Current URL: ${guardianPage.url()}`);
+ // Check for error messages on the page
+ const errorElement = guardianPage.locator("[role=\"alert\"], .error, [class*=\"error\"]").first();
+ if (await errorElement.isVisible({ timeout: 1000 }).catch(() => false)) {
+ const errorText = await errorElement.textContent();
+ console.log(`Guardian error message: ${errorText}`);
+ }
+ throw e;
+ }
+ await guardianPage.waitForTimeout(2000);
+
+ // Wait for account address to be visible
+ await expect(guardianPage.getByTestId("account-address")).toBeVisible({ timeout: 10000 });
+ const guardianAddressText = await guardianPage.getByTestId("account-address").getAttribute("data-address") || "";
+ console.log(`Guardian account created: ${guardianAddressText}`);
+
+ // Fund the guardian account with ETH
+ await fundAccount(guardianAddressText, "1");
+
+ // ===== Step 3: Navigate to Guardian Settings and Propose ===
+ console.log("\nStep 3: Proposing guardian with paymaster...");
+ await page.bringToFront();
+
+ // Capture console logs from the primary page during proposal
+ const primaryPageConsole: string[] = [];
+ page.on("console", (msg) => {
+ const logMsg = `[${msg.type()}] ${msg.text()}`;
+ primaryPageConsole.push(logMsg);
+ console.log(`Primary page console: ${logMsg}`);
+ });
+
+ await page.goto("http://localhost:3002/dashboard/settings");
+ await page.waitForTimeout(2000);
+
+ const addGuardianButton = page.getByTestId("add-recovery-method");
+ await expect(addGuardianButton).toBeVisible({ timeout: 10000 });
+ await addGuardianButton.click();
+ await page.waitForTimeout(1000);
+
+ // Select "Recover with Guardian" option
+ const guardianMethodButton = page.getByTestId("add-guardian-method");
+ await expect(guardianMethodButton).toBeVisible({ timeout: 5000 });
+ await guardianMethodButton.click();
+ await page.waitForTimeout(1000);
+
+ const continueButton = page.getByTestId("continue-recovery-method");
+ await expect(continueButton).toBeVisible({ timeout: 10000 });
+ await continueButton.click();
+ await page.waitForTimeout(1000);
+
+ const guardianInput = page.getByTestId("guardian-address-input").locator("input");
+ await expect(guardianInput).toBeVisible({ timeout: 5000 });
+ await guardianInput.fill(guardianAddressText);
+
+ const proposeButton = page.getByRole("button", { name: /Propose/i, exact: false });
+ await proposeButton.click();
+
+ // Wait for paymaster-sponsored guardian proposal to complete
+ // NOTE: The SSO client automatically signs ERC-4337 transactions with paymaster,
+ // so there are NO popup windows to interact with during guardian proposal
+ console.log("Waiting for paymaster-sponsored guardian proposal to complete...");
+ await page.waitForTimeout(8000); // Wait for module installation + guardian proposal
+ console.log("Guardian proposal with paymaster initiated");
+
+ // Log all captured console messages
+ if (primaryPageConsole.length > 0) {
+ console.log(`\nš Captured ${primaryPageConsole.length} console messages from primary page:`);
+ primaryPageConsole.forEach((msg, i) => console.log(` ${i + 1}. ${msg}`));
+ }
+
+ // Handle "Do you wish to confirm your guardian now?" dialog
+ try {
+ const confirmLaterButton = page.getByRole("button", { name: /Confirm Later/i });
+ await confirmLaterButton.waitFor({ state: "visible", timeout: 10000 });
+ await confirmLaterButton.click();
+ console.log("Clicked 'Confirm Later' on the confirmation prompt");
+ await page.waitForTimeout(5000); // Wait for dialog to close and transaction to complete
+ } catch (error) {
+ console.log("ā Warning: 'Do you wish to confirm your guardian now?' prompt not found");
+ console.log(` ${error instanceof Error ? error.message : String(error)}`);
+ }
+
+ // ===== Step 4: Confirm Guardian with Paymaster =====
+ console.log("\nStep 4: Confirming guardian with paymaster...");
+ const confirmUrl = `http://localhost:3002/recovery/guardian/confirm-guardian?accountAddress=${primaryAddressText}&guardianAddress=${guardianAddressText}`;
+
+ // Capture page errors for paymaster test
+ const paymasterPageErrors: string[] = [];
+ guardianPage.on("pageerror", (error) => {
+ const errorMsg = `Page error: ${error.message}`;
+ console.error(errorMsg);
+ paymasterPageErrors.push(errorMsg);
+ });
+
+ guardianPage.on("console", (msg) => {
+ if (msg.type() === "error") {
+ console.error(`Console error: ${msg.text()}`);
+ }
+ });
+
+ // Bring guardian page to front and wait for stable state
+ await guardianPage.bringToFront();
+ await guardianPage.waitForLoadState("domcontentloaded");
+ await guardianPage.waitForTimeout(1000);
+
+ // Check if guardianPage is still valid
+ console.log(`Guardian page URL before navigation: ${guardianPage.url()}`);
+ console.log(`Guardian page is closed: ${guardianPage.isClosed()}`);
+
+ // Navigate to confirmation URL
+ console.log(`Navigating to confirmation URL: ${confirmUrl}`);
+ await guardianPage.goto(confirmUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
+ console.log("Navigation completed successfully");
+ await guardianPage.waitForTimeout(2000);
+
+ // Debug: Log page state
+ console.log(`Current URL: ${guardianPage.url()}`);
+ console.log(`Page title: ${await guardianPage.title()}`);
+
+ // Debug: Take screenshot
+ await guardianPage.screenshot({ path: "test-results/confirm-guardian-paymaster-debug.png" });
+ console.log("Screenshot saved to test-results/confirm-guardian-paymaster-debug.png");
+
+ // Debug: List all buttons
+ const allButtons = await guardianPage.locator("button").all();
+ console.log(`\nFound ${allButtons.length} button elements on page`);
+ for (let i = 0; i < allButtons.length; i++) {
+ const isVisible = await allButtons[i].isVisible().catch(() => false);
+ const text = await allButtons[i].textContent().catch(() => "N/A");
+ console.log(` Button ${i + 1}: visible=${isVisible}, text="${text}"`);
+ }
+
+ // Debug: Check page content
+ const bodyText = await guardianPage.locator("body").textContent();
+ console.log(`\nPage body text (first 500 chars): ${bodyText?.substring(0, 500)}`);
+
+ // Debug: Check for loading states
+ const loadingElements = await guardianPage.locator("[class*=\"loading\"], [class*=\"Loading\"], [class*=\"spinner\"]").all();
+ console.log(`\nFound ${loadingElements.length} loading indicator(s)`);
+
+ if (paymasterPageErrors.length > 0) {
+ console.log(`\nAccumulated page errors (${paymasterPageErrors.length}):`);
+ paymasterPageErrors.forEach((err, i) => console.log(` ${i + 1}. ${err}`));
+ }
+
+ // Check if we need to sign in or if already logged in as guardian
+ console.log("\nChecking authentication state...");
+ const confirmGuardianButton = guardianPage.getByRole("button", { name: /Confirm Guardian/i });
+ const signInSsoButton = guardianPage.getByTestId("sign-in-sso");
+ const isSignInVisible = await signInSsoButton.isVisible({ timeout: 2000 }).catch(() => false);
+
+ if (isSignInVisible) {
+ console.log("Sign in required, clicking sign-in button...");
+ await signInSsoButton.click();
+
+ // Wait for redirect to login page
+ await guardianPage.waitForTimeout(2000);
+
+ // Should be redirected to login/signup page - use existing credential
+ await reuseCredential(guardianPage, guardianCredential!);
+
+ // Click signin button
+ const signInButton = guardianPage.getByTestId("signin");
+ if (await signInButton.isVisible({ timeout: 2000 }).catch(() => false)) {
+ await signInButton.click();
+ // Wait for redirect back to confirmation page
+ await guardianPage.waitForURL("**/confirm-guardian**", { timeout: 10000 });
+ await guardianPage.waitForTimeout(2000);
+ }
+ } else {
+ console.log("Already logged in as guardian SSO account, proceeding to confirm...");
+ }
+
+ // Now confirm guardian should be visible
+ await expect(confirmGuardianButton).toBeVisible({ timeout: 10000 });
+ console.log("Clicking Confirm Guardian button...");
+ await confirmGuardianButton.click();
+
+ console.log("Waiting for paymaster-sponsored confirmation transaction...");
+ await guardianPage.waitForTimeout(3000);
+
+ // Check if we need to sign the confirmation (popup might appear)
+ const guardianPagesBefore = guardianContext.pages().length;
+ await guardianPage.waitForTimeout(1000);
+ if (guardianContext.pages().length > guardianPagesBefore) {
+ const guardianSignPopup = guardianContext.pages()[guardianContext.pages().length - 1];
+ console.log("Guardian signing popup detected...");
+ await reuseCredential(guardianSignPopup, guardianCredential!);
+ const confirmBtn = guardianSignPopup.getByTestId("confirm");
+ if (await confirmBtn.isVisible({ timeout: 5000 })) {
+ await confirmBtn.click();
+ await guardianPage.waitForTimeout(3000);
+ }
+ }
+
+ // Verify success
+ await guardianPage.waitForTimeout(5000);
+ const successIndicator = guardianPage.getByText(/Guardian.*confirmed|Success|Confirmed/i).first();
+ if (await successIndicator.isVisible({ timeout: 5000 })) {
+ console.log("ā
Guardian confirmation success message visible");
+ } else {
+ console.log("ā ļø No explicit success message found after paymaster confirmation");
+ }
+
+ console.log("\n=== Guardian with Paymaster E2E Test Complete ===\n");
+
+ // Cleanup
+ await guardianContext.close();
+});
+
+test("Guardian flow: full recovery execution", async ({ page, context: baseContext }) => {
+ test.setTimeout(120000); // Extended timeout for full recovery flow
+ console.log("\n=== Starting Full Recovery Execution E2E Test ===\n");
+
+ // Step 1: Create account owner
+ console.log("Step 1: Creating account owner...");
+ await page.goto("http://localhost:3002");
+
+ const { newCredential: _ownerCredential } = await setupWebAuthn(page);
+ await page.getByTestId("signup").click();
+
+ await page.waitForURL("**/dashboard", { timeout: 15000 });
+ await page.waitForTimeout(2000);
+
+ await expect(page.getByTestId("account-address")).toBeVisible({ timeout: 10000 });
+ const ownerAddress = await page.getByTestId("account-address").getAttribute("data-address") || "";
+ console.log(`ā
Owner account created: ${ownerAddress}`);
+
+ await fundAccount(ownerAddress);
+
+ // Step 2: Create guardian account
+ console.log("\nStep 2: Creating guardian account...");
+ const guardianContext = await baseContext.browser()!.newContext();
+ const guardianPage = await guardianContext.newPage();
+
+ await guardianPage.goto("http://localhost:3002");
+ await guardianPage.waitForTimeout(2000);
+
+ const { newCredential: _guardianCredential } = await setupWebAuthn(guardianPage);
+ await guardianPage.getByTestId("signup").click();
+
+ await guardianPage.waitForURL("**/dashboard", { timeout: 15000 });
+ await guardianPage.waitForTimeout(2000);
+
+ await expect(guardianPage.getByTestId("account-address")).toBeVisible({ timeout: 10000 });
+ const guardianAddress = await guardianPage.getByTestId("account-address").getAttribute("data-address") || "";
+ console.log(`ā
Guardian account created: ${guardianAddress}`);
+
+ await fundAccount(guardianAddress);
+
+ // Step 3: Owner proposes guardian
+ console.log("\nStep 3: Owner proposing guardian...");
+
+ // Capture console logs from the owner page during proposal
+ const ownerPageConsole: string[] = [];
+ page.on("console", (msg) => {
+ const logMsg = `[${msg.type()}] ${msg.text()}`;
+ ownerPageConsole.push(logMsg);
+ console.log(`Owner page console: ${logMsg}`);
+ });
+
+ await page.goto("http://localhost:3002/dashboard/settings");
+ await page.waitForTimeout(2000);
+
+ const addRecoveryButton = page.getByTestId("add-recovery-method");
+ await expect(addRecoveryButton).toBeVisible({ timeout: 10000 });
+ await addRecoveryButton.click();
+ await page.waitForTimeout(1000);
+
+ const guardianMethodButton = page.getByTestId("add-guardian-method");
+ await expect(guardianMethodButton).toBeVisible({ timeout: 5000 });
+ await guardianMethodButton.click();
+ await page.waitForTimeout(1000);
+
+ const continueButton = page.getByTestId("continue-recovery-method");
+ await expect(continueButton).toBeVisible({ timeout: 10000 });
+ await continueButton.click();
+ await page.waitForTimeout(1000);
+
+ const guardianAddressInput = page.getByTestId("guardian-address-input").locator("input");
+ await expect(guardianAddressInput).toBeVisible({ timeout: 10000 });
+ await guardianAddressInput.fill(guardianAddress);
+
+ const proposeButton = page.getByRole("button", { name: /Propose/i, exact: false });
+ await proposeButton.click();
+
+ // Wait for guardian proposal to complete
+ const errorMessage = page.locator("text=/error.*proposing/i");
+ const errorVisible = await errorMessage.isVisible({ timeout: 8000 }).catch(() => false);
+ if (errorVisible) {
+ const errorText = await errorMessage.textContent();
+ throw new Error(`Guardian proposal failed: ${errorText}`);
+ }
+
+ await page.waitForTimeout(8000);
+ console.log("ā
Guardian proposed successfully");
+
+ // Log all captured console messages
+ if (ownerPageConsole.length > 0) {
+ console.log(`\nš Captured ${ownerPageConsole.length} console messages from owner page:`);
+ ownerPageConsole.forEach((msg, i) => console.log(` ${i + 1}. ${msg}`));
+ }
+
+ // Step 4: Guardian accepts role
+ console.log("\nStep 4: Guardian accepting role...");
+ const confirmUrl = `http://localhost:3002/recovery/guardian/confirm-guardian?accountAddress=${ownerAddress}&guardianAddress=${guardianAddress}`;
+ console.log(`Confirmation URL: ${confirmUrl}`);
+ await guardianPage.goto(confirmUrl);
+ await guardianPage.waitForTimeout(2000);
+
+ const confirmButton = guardianPage.getByTestId("confirm-guardian-button");
+ await expect(confirmButton).toBeVisible({ timeout: 10000 });
+
+ // Check button state before clicking
+ const isButtonDisabled = await confirmButton.isDisabled();
+ console.log(`Button disabled state before click: ${isButtonDisabled}`);
+
+ await confirmButton.click();
+ console.log("Clicked confirm button, waiting for response...");
+
+ // Wait and check state multiple times to see if it progresses or gets stuck
+ await guardianPage.waitForTimeout(2000);
+
+ const stateElement = guardianPage.locator("text=Current State:").locator("xpath=following-sibling::span");
+
+ // Check state progression over 30 seconds (15 iterations Ć 2 seconds)
+ // Allows time for: event queries (10s) + transaction confirmation (10s) + buffer
+ for (let i = 0; i < 25; i++) {
+ const currentState = await stateElement.textContent().catch(() => "unknown");
+ console.log(`[${i * 2}s] Current confirmation state: ${currentState}`);
+
+ // Check for UI error after each state check
+ const errorElement = guardianPage.locator("p.text-error-600, p.text-error-400");
+ const hasError = await errorElement.isVisible().catch(() => false);
+
+ if (hasError) {
+ const errorText = await errorElement.textContent();
+ console.log("ā Guardian confirmation failed with UI error:", errorText);
+ throw new Error(`Guardian confirmation failed: ${errorText}`);
+ }
+
+ // If state shows error, extract it
+ if (currentState?.startsWith("error:")) {
+ console.log("ā Confirmation state shows error:", currentState);
+ throw new Error(`Guardian confirmation failed: ${currentState}`);
+ }
+
+ // If we reached a final state, break
+ if (currentState === "complete" || currentState === "confirm_guardian_completed") {
+ break;
+ }
+
+ // If stuck in getting state for more than 16 seconds, that's the issue
+ // (Allows 5s for getBlockNumber + 10s for event queries + buffer)
+ if (i >= 8 && currentState?.includes("getting")) {
+ console.log(`ā ļø Stuck in ${currentState} for ${i * 2} seconds`);
+ await guardianPage.screenshot({ path: "test-results/stuck-getting-client-debug.png" });
+ const bodyText = await guardianPage.locator("body").textContent();
+ console.log("Page debug state:", bodyText?.match(/Current State:.*?Expected flow:/s)?.[0] || "Not found");
+ throw new Error(`Guardian confirmation stuck in state: ${currentState}. getConfigurableAccount or getWalletClient is hanging/failing silently.`);
+ }
+
+ await guardianPage.waitForTimeout(2000);
+ }
+
+ // Check for successful completion
+ const finalState = await stateElement.textContent().catch(() => "unknown");
+ if (finalState !== "complete" && finalState !== "confirm_guardian_completed") {
+ console.log("ā ļø Guardian confirmation did not complete. Final state:", finalState);
+ throw new Error(`Guardian confirmation failed. Final state: ${finalState}`);
+ }
+ console.log("ā
Guardian confirmed successfully. Final state:", finalState);
+
+ // Step 5: Owner initiates recovery with new passkey using "Confirm Now" flow
+ console.log("\nStep 5: Owner initiating account recovery with Confirm Now...");
+
+ // Create new recovery credential
+ const recoveryContext = await baseContext.browser()!.newContext();
+ const recoveryPage = await recoveryContext.newPage();
+
+ // Navigate directly to guardian recovery page
+ await recoveryPage.goto("http://localhost:3002/recovery/guardian");
+ await recoveryPage.waitForTimeout(2000);
+
+ // Enter owner account address - find the input inside the ZkInput component
+ const accountInput = recoveryPage.locator("#address input");
+ await expect(accountInput).toBeVisible({ timeout: 10000 });
+ await accountInput.fill(ownerAddress);
+ await recoveryPage.waitForTimeout(1000);
+
+ // Click Continue button
+ const continueBtn = recoveryPage.getByRole("button", { name: /Continue/i });
+ await expect(continueBtn).toBeEnabled({ timeout: 5000 });
+ await continueBtn.click();
+ await recoveryPage.waitForTimeout(2000);
+
+ // Set up WebAuthn mock for recovery passkey
+ await setupWebAuthn(recoveryPage);
+
+ const createPasskeyBtn = recoveryPage.getByRole("button", { name: /Generate Passkey/i });
+ await expect(createPasskeyBtn).toBeVisible({ timeout: 10000 });
+ await createPasskeyBtn.click();
+ await recoveryPage.waitForTimeout(3000);
+
+ // Step 6: Click "Confirm Later" to get the recovery URL
+ console.log("\nStep 6: Getting recovery confirmation URL...");
+
+ // Click "Confirm Later" to get the URL
+ const confirmLaterBtn = recoveryPage.getByRole("button", { name: /Confirm Later/i });
+ await expect(confirmLaterBtn).toBeVisible({ timeout: 10000 });
+ await confirmLaterBtn.click();
+ await recoveryPage.waitForTimeout(2000);
+
+ // Find and extract the recovery URL from the page
+ const recoveryLink = recoveryPage.locator("a[href*=\"/recovery/guardian/confirm-recovery\"]");
+ await expect(recoveryLink).toBeVisible({ timeout: 10000 });
+ const recoveryConfirmationUrl = await recoveryLink.getAttribute("href");
+ console.log(`Recovery confirmation URL: ${recoveryConfirmationUrl}`);
+
+ if (!recoveryConfirmationUrl) {
+ throw new Error("Failed to capture recovery confirmation URL");
+ }
+
+ // Close the owner's recovery page and context
+ await recoveryPage.close();
+ await recoveryContext.close();
+
+ // Step 7: Guardian confirms recovery
+ console.log("\nStep 7: Guardian confirming recovery...");
+
+ // Navigate directly to the recovery confirmation URL
+ await guardianPage.goto(recoveryConfirmationUrl);
+ await guardianPage.waitForLoadState("networkidle");
+
+ // Wait for the guardian select dropdown to load
+ const guardianSelect = guardianPage.locator("select");
+ await expect(guardianSelect).toBeVisible({ timeout: 15000 });
+
+ // Debug: Check what options are actually in the dropdown
+ await guardianPage.waitForTimeout(3000); // Give time for async guardians to load
+ const allOptions = await guardianSelect.locator("option").all();
+ console.log(`\nFound ${allOptions.length} option(s) in guardian dropdown:`);
+ for (const option of allOptions) {
+ const value = await option.getAttribute("value");
+ const text = await option.textContent();
+ console.log(` - value="${value}" text="${text}"`);
+ }
+
+ // Try to select the guardian (try both cases)
+ const normalizedGuardianAddress = guardianAddress.toLowerCase();
+ console.log(`\nLooking for guardian: ${guardianAddress}`);
+ console.log(`Normalized (lowercase): ${normalizedGuardianAddress}`);
+
+ // Check if the option exists in either case
+ let optionFound = false;
+ for (const option of allOptions) {
+ const value = await option.getAttribute("value");
+ if (value && value.toLowerCase() === normalizedGuardianAddress) {
+ console.log(`ā
Found matching option with value: ${value}`);
+ await guardianSelect.selectOption({ value: value });
+ optionFound = true;
+ break;
+ }
+ }
+
+ if (!optionFound) {
+ throw new Error(`Guardian option not found in dropdown. Expected: ${guardianAddress} (or ${normalizedGuardianAddress}). Available options: ${allOptions.length > 0 ? (await Promise.all(allOptions.map(async (o) => await o.getAttribute("value")))).join(", ") : "none"}`);
+ }
+
+ await guardianPage.waitForTimeout(1000);
+
+ // Capture console logs and errors from guardian page
+ const guardianConsoleMessages: string[] = [];
+ guardianPage.on("console", (msg) => {
+ const logMsg = `[${msg.type()}] ${msg.text()}`;
+ guardianConsoleMessages.push(logMsg);
+ console.log(`Guardian console: ${logMsg}`);
+ });
+
+ guardianPage.on("pageerror", (error) => {
+ const errorMsg = `Page error: ${error.message}`;
+ console.error(`Guardian ${errorMsg}`);
+ guardianConsoleMessages.push(errorMsg);
+ });
+
+ // Click Confirm Recovery button
+ const confirmRecoveryBtn = guardianPage.getByRole("button", { name: /Confirm Recovery/i });
+ await expect(confirmRecoveryBtn).toBeVisible({ timeout: 10000 });
+ await expect(confirmRecoveryBtn).toBeEnabled({ timeout: 5000 });
+ await confirmRecoveryBtn.click();
+
+ console.log("Clicked Confirm Recovery, waiting for success message...");
+
+ // Wait longer for recovery confirmation and polling to complete
+ await guardianPage.waitForTimeout(20000);
+
+ // Print captured console messages
+ if (guardianConsoleMessages.length > 0) {
+ console.log(`\nš Guardian page console messages (${guardianConsoleMessages.length}):`);
+ guardianConsoleMessages.forEach((msg, i) => {
+ console.log(` ${i + 1}. ${msg}`);
+ });
+ } else {
+ console.log("\nā ļø No console messages captured from guardian page");
+ }
+
+ // Check for error messages on the page before checking for success
+ const errorElement = guardianPage.locator("text=/error/i").first();
+ const hasError = await errorElement.isVisible({ timeout: 2000 }).catch(() => false);
+ if (hasError) {
+ const errorText = await errorElement.textContent();
+ console.error(`ā Error message on page: ${errorText}`);
+ await guardianPage.screenshot({ path: "test-results/confirm-recovery-error.png" });
+ throw new Error(`Recovery confirmation failed: ${errorText}`);
+ }
+
+ // Verify recovery was initiated - look for success message
+ const successMessage = guardianPage.getByText(/24hrs/i)
+ .or(guardianPage.getByText(/24 hours/i))
+ .or(guardianPage.getByText(/recovery.*initiated/i));
+
+ await expect(successMessage).toBeVisible({ timeout: 20000 });
+
+ console.log("ā
Guardian confirmed recovery successfully");
+
+ // Step 7: Verify recovery is pending (skip execution for now as it requires time travel)
+ console.log("\nStep 7: Verifying recovery is in pending state...");
+ console.log("Note: Full recovery execution requires EVM time manipulation");
+ console.log("The recovery would need to wait 24 hours before it can be finalized");
+
+ console.log("\n=== Full Recovery E2E Flow Complete ===\n");
+ console.log("Summary:");
+ console.log(" ā
Owner account created");
+ console.log(" ā
Guardian account created");
+ console.log(" ā
Guardian proposed by owner");
+ console.log(" ā
Guardian confirmed their role");
+ console.log(" ā
Owner initiated recovery with new passkey");
+ console.log(" ā
Guardian confirmed the recovery request");
+ console.log(" ā³ Recovery pending (24hr delay before finalization)");
+
+ // Cleanup
+ await guardianContext.close();
+});
diff --git a/packages/sdk-4337/package.json b/packages/sdk-4337/package.json
index ce921e256..7185ae4a1 100644
--- a/packages/sdk-4337/package.json
+++ b/packages/sdk-4337/package.json
@@ -98,6 +98,11 @@
"import": "./dist/_esm/client/passkey/index.js",
"require": "./dist/_cjs/client/passkey/index.js"
},
+ "./utils": {
+ "types": "./dist/_types/client/passkey/index.d.ts",
+ "import": "./dist/_esm/client/passkey/index.js",
+ "require": "./dist/_cjs/client/passkey/index.js"
+ },
"./actions/sendUserOperation": {
"types": "./dist/_types/actions/sendUserOperation.d.ts",
"import": "./dist/_esm/actions/sendUserOperation.js",
diff --git a/packages/sdk-4337/src/abi/AAFactory.ts b/packages/sdk-4337/src/abi/AAFactory.ts
new file mode 100644
index 000000000..0ba496856
--- /dev/null
+++ b/packages/sdk-4337/src/abi/AAFactory.ts
@@ -0,0 +1,230 @@
+export const AAFactoryAbi = [
+ {
+ inputs: [
+ {
+ internalType: "bytes32",
+ name: "_beaconProxyBytecodeHash",
+ type: "bytes32",
+ },
+ {
+ internalType: "address",
+ name: "_beacon",
+ type: "address",
+ },
+ {
+ internalType: "address",
+ name: "_passKeyModule",
+ type: "address",
+ },
+ {
+ internalType: "address",
+ name: "_sessionKeyModule",
+ type: "address",
+ },
+ ],
+ stateMutability: "nonpayable",
+ type: "constructor",
+ },
+ {
+ inputs: [
+ {
+ internalType: "address",
+ name: "account",
+ type: "address",
+ },
+ ],
+ name: "ACCOUNT_ALREADY_EXISTS",
+ type: "error",
+ },
+ {
+ inputs: [],
+ name: "EMPTY_BEACON_ADDRESS",
+ type: "error",
+ },
+ {
+ inputs: [],
+ name: "EMPTY_BEACON_BYTECODE_HASH",
+ type: "error",
+ },
+ {
+ inputs: [],
+ name: "EMPTY_PASSKEY_ADDRESS",
+ type: "error",
+ },
+ {
+ inputs: [],
+ name: "EMPTY_SESSIONKEY_ADDRESS",
+ type: "error",
+ },
+ {
+ inputs: [],
+ name: "INVALID_ACCOUNT_KEYS",
+ type: "error",
+ },
+ {
+ anonymous: false,
+ inputs: [
+ {
+ indexed: true,
+ internalType: "address",
+ name: "accountAddress",
+ type: "address",
+ },
+ {
+ indexed: false,
+ internalType: "bytes32",
+ name: "uniqueAccountId",
+ type: "bytes32",
+ },
+ ],
+ name: "AccountCreated",
+ type: "event",
+ },
+ {
+ inputs: [
+ {
+ internalType: "bytes32",
+ name: "accountId",
+ type: "bytes32",
+ },
+ ],
+ name: "accountMappings",
+ outputs: [
+ {
+ internalType: "address",
+ name: "deployedAccount",
+ type: "address",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [],
+ name: "beacon",
+ outputs: [
+ {
+ internalType: "address",
+ name: "",
+ type: "address",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [],
+ name: "beaconProxyBytecodeHash",
+ outputs: [
+ {
+ internalType: "bytes32",
+ name: "",
+ type: "bytes32",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "bytes32",
+ name: "uniqueId",
+ type: "bytes32",
+ },
+ {
+ internalType: "bytes",
+ name: "passKey",
+ type: "bytes",
+ },
+ {
+ internalType: "bytes",
+ name: "sessionKey",
+ type: "bytes",
+ },
+ {
+ internalType: "address[]",
+ name: "ownerKeys",
+ type: "address[]",
+ },
+ ],
+ name: "deployModularAccount",
+ outputs: [
+ {
+ internalType: "address",
+ name: "accountAddress",
+ type: "address",
+ },
+ ],
+ stateMutability: "nonpayable",
+ type: "function",
+ },
+ {
+ inputs: [
+ {
+ internalType: "bytes32",
+ name: "uniqueId",
+ type: "bytes32",
+ },
+ {
+ internalType: "bytes[]",
+ name: "initialValidators",
+ type: "bytes[]",
+ },
+ {
+ internalType: "address[]",
+ name: "initialK1Owners",
+ type: "address[]",
+ },
+ ],
+ name: "deployProxySsoAccount",
+ outputs: [
+ {
+ internalType: "address",
+ name: "accountAddress",
+ type: "address",
+ },
+ ],
+ stateMutability: "nonpayable",
+ type: "function",
+ },
+ {
+ inputs: [],
+ name: "getEncodedBeacon",
+ outputs: [
+ {
+ internalType: "bytes",
+ name: "",
+ type: "bytes",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [],
+ name: "passKeyModule",
+ outputs: [
+ {
+ internalType: "address",
+ name: "",
+ type: "address",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+ {
+ inputs: [],
+ name: "sessionKeyModule",
+ outputs: [
+ {
+ internalType: "address",
+ name: "",
+ type: "address",
+ },
+ ],
+ stateMutability: "view",
+ type: "function",
+ },
+] as const;
diff --git a/packages/sdk-4337/src/abi/index.ts b/packages/sdk-4337/src/abi/index.ts
index 74a4ead1e..96079291f 100644
--- a/packages/sdk-4337/src/abi/index.ts
+++ b/packages/sdk-4337/src/abi/index.ts
@@ -1,2 +1,3 @@
+export * from "./AAFactory.js";
export * from "./SessionKeyValidator.js";
export * from "./WebAuthnValidator.js";
diff --git a/packages/sdk-4337/src/client/actions/deploy.ts b/packages/sdk-4337/src/client/actions/deploy.ts
index 6996b4bdf..40e76a98f 100644
--- a/packages/sdk-4337/src/client/actions/deploy.ts
+++ b/packages/sdk-4337/src/client/actions/deploy.ts
@@ -38,6 +38,9 @@ export type PrepareDeploySmartAccountParams = {
/** Optional: Install session validator module during deployment */
installSessionValidator?: boolean;
+ /** Optional array of executor module addresses to install during deployment */
+ executorModules?: Address[];
+
/** Optional user ID for deterministic account deployment. If provided, generates deterministic accountId from userId */
userId?: string;
@@ -103,6 +106,7 @@ export function prepareDeploySmartAccount(
userId,
accountId: customAccountId,
installSessionValidator,
+ executorModules,
} = params;
// Validation: Check that required validators are provided
@@ -170,6 +174,7 @@ export function prepareDeploySmartAccount(
passkeyPayload as any,
contracts.webauthnValidator || null,
(installSessionValidator && contracts.sessionValidator) || null,
+ executorModules || [],
) as Hex;
return {
diff --git a/packages/sdk-4337/src/client/actions/index.ts b/packages/sdk-4337/src/client/actions/index.ts
index 074b22343..9adaf89a6 100644
--- a/packages/sdk-4337/src/client/actions/index.ts
+++ b/packages/sdk-4337/src/client/actions/index.ts
@@ -21,10 +21,19 @@ export { getAccountAddressFromLogs, prepareDeploySmartAccount } from "./deploy.j
export type {
AddPasskeyParams,
AddPasskeyResult,
+ FetchAccountParams,
+ FetchAccountResult,
FindAddressesByPasskeyParams,
FindAddressesByPasskeyResult,
} from "./passkey.js";
-export { addPasskey, findAddressesByPasskey } from "./passkey.js";
+export { addPasskey, fetchAccount, findAddressesByPasskey } from "./passkey.js";
+
+// Module management exports
+export type {
+ IsModuleInstalledParams,
+ IsModuleInstalledResult,
+} from "./modules.js";
+export { isGuardianModuleInstalled, isModuleInstalled, ModuleType } from "./modules.js";
// Utilities
export { generateAccountId } from "./utils.js";
diff --git a/packages/sdk-4337/src/client/actions/modules.ts b/packages/sdk-4337/src/client/actions/modules.ts
new file mode 100644
index 000000000..09afe4b91
--- /dev/null
+++ b/packages/sdk-4337/src/client/actions/modules.ts
@@ -0,0 +1,112 @@
+/**
+ * Module management actions for ERC-7579 smart accounts
+ */
+
+import type { Address, Hex, PublicClient } from "viem";
+
+/**
+ * ERC-7579 module type IDs
+ */
+export const ModuleType = {
+ VALIDATOR: 1n,
+ EXECUTOR: 2n,
+ FALLBACK: 3n,
+ HOOK: 4n,
+} as const;
+
+/**
+ * Minimal ABI for isModuleInstalled function on ERC-7579 accounts
+ */
+const IERC7579AccountAbi = [
+ {
+ type: "function",
+ name: "isModuleInstalled",
+ inputs: [
+ { name: "moduleTypeId", type: "uint256", internalType: "uint256" },
+ { name: "module", type: "address", internalType: "address" },
+ { name: "additionalContext", type: "bytes", internalType: "bytes" },
+ ],
+ outputs: [{ name: "", type: "bool", internalType: "bool" }],
+ stateMutability: "view",
+ },
+] as const;
+
+export type IsModuleInstalledParams = {
+ /** Public client for reading contract state */
+ client: PublicClient;
+ /** The smart account address to check */
+ accountAddress: Address;
+ /** The module address to check for installation */
+ moduleAddress: Address;
+ /** The module type ID (use ModuleType constants) */
+ moduleTypeId: bigint;
+ /** Optional additional context for module lookup (usually empty) */
+ additionalContext?: Hex;
+};
+
+export type IsModuleInstalledResult = {
+ /** Whether the module is installed on the account */
+ isInstalled: boolean;
+};
+
+/**
+ * Check if a module is installed on an ERC-7579 smart account
+ *
+ * @example
+ * ```typescript
+ * const result = await isModuleInstalled({
+ * client: publicClient,
+ * accountAddress: "0x...",
+ * moduleAddress: "0x...",
+ * moduleTypeId: ModuleType.EXECUTOR,
+ * });
+ * console.log("Guardian module installed:", result.isInstalled);
+ * ```
+ */
+export async function isModuleInstalled(
+ params: IsModuleInstalledParams,
+): Promise {
+ const {
+ client,
+ accountAddress,
+ moduleAddress,
+ moduleTypeId,
+ additionalContext = "0x",
+ } = params;
+
+ const isInstalled = await client.readContract({
+ address: accountAddress,
+ abi: IERC7579AccountAbi,
+ functionName: "isModuleInstalled",
+ args: [moduleTypeId, moduleAddress, additionalContext],
+ });
+
+ return { isInstalled };
+}
+
+/**
+ * Check if the guardian executor module is installed on an account
+ *
+ * @example
+ * ```typescript
+ * const result = await isGuardianModuleInstalled({
+ * client: publicClient,
+ * accountAddress: "0x...",
+ * guardianExecutorAddress: "0x...",
+ * });
+ * if (!result.isInstalled) {
+ * console.warn("Guardian module not installed on account");
+ * }
+ * ```
+ */
+export async function isGuardianModuleInstalled(params: {
+ client: PublicClient;
+ accountAddress: Address;
+ guardianExecutorAddress: Address;
+}): Promise {
+ return isModuleInstalled({
+ ...params,
+ moduleAddress: params.guardianExecutorAddress,
+ moduleTypeId: ModuleType.EXECUTOR,
+ });
+}
diff --git a/packages/sdk-4337/src/client/actions/passkey.ts b/packages/sdk-4337/src/client/actions/passkey.ts
index 489f8ebc3..a9336b4d8 100644
--- a/packages/sdk-4337/src/client/actions/passkey.ts
+++ b/packages/sdk-4337/src/client/actions/passkey.ts
@@ -1,5 +1,6 @@
import type { Address, Hex, PublicClient } from "viem";
-import { hexToBytes } from "viem";
+import { hexToBytes, toHex } from "viem";
+import { readContract } from "viem/actions";
import {
decode_get_account_list_result,
encode_add_passkey_call_data,
@@ -7,6 +8,9 @@ import {
PasskeyPayload,
} from "zksync-sso-web-sdk/bundler";
+import { WebAuthnValidatorAbi } from "../../abi/WebAuthnValidator.js";
+import { base64urlToUint8Array } from "../passkey/webauthn.js";
+
/**
* Parameters for adding a passkey to a smart account
*/
@@ -197,3 +201,144 @@ export async function findAddressesByPasskey(
return { addresses };
}
+
+/**
+ * Parameters for fetching account details by passkey
+ */
+export type FetchAccountParams = {
+ /** Public client for making RPC calls */
+ client: PublicClient;
+
+ /** Contract addresses */
+ contracts: {
+ /** WebAuthn validator address */
+ webauthnValidator: Address;
+ };
+
+ /** Origin domain (e.g., "https://example.com" or window.location.origin) */
+ originDomain: string;
+
+ /** Optional: credential ID if known, otherwise will prompt user */
+ credentialId?: string;
+};
+
+/**
+ * Result from fetching account details
+ */
+export type FetchAccountResult = {
+ /** Credential ID (username) */
+ credentialId: string;
+ /** Smart account address */
+ address: Address;
+ /** Passkey public key as Uint8Array (COSE format) */
+ passkeyPublicKey: Uint8Array;
+};
+
+/**
+ * Fetch account details for a passkey credential.
+ * If credentialId is not provided, will prompt the user to select a passkey.
+ * This queries the WebAuthnValidator contract to get the account address and public key.
+ *
+ * @param params - Parameters including client, validator address, and optional credential ID
+ * @returns Account details including credentialId, address, and public key
+ *
+ * @example
+ * ```typescript
+ * import { createPublicClient, http } from "viem";
+ * import { fetchAccount } from "zksync-sso-4337/actions";
+ *
+ * const publicClient = createPublicClient({
+ * chain: zkSyncSepoliaTestnet,
+ * transport: http(),
+ * });
+ *
+ * const { credentialId, address, passkeyPublicKey } = await fetchAccount({
+ * client: publicClient,
+ * contracts: {
+ * webauthnValidator: "0x...",
+ * },
+ * originDomain: window.location.origin,
+ * });
+ *
+ * console.log(`Account address: ${address}`);
+ * ```
+ */
+export async function fetchAccount(
+ params: FetchAccountParams,
+): Promise {
+ const { client, contracts, originDomain, credentialId: providedCredentialId } = params;
+
+ let credentialId = providedCredentialId;
+
+ // If no credential ID provided, prompt user to select a passkey
+ if (!credentialId) {
+ const credential = (await navigator.credentials.get({
+ publicKey: {
+ challenge: new Uint8Array(32), // Dummy challenge
+ userVerification: "discouraged",
+ },
+ })) as PublicKeyCredential | null;
+
+ if (!credential) {
+ throw new Error("No passkey credential selected");
+ }
+
+ credentialId = credential.id;
+ }
+
+ // Convert credential ID to hex
+ const credentialIdHex = toHex(base64urlToUint8Array(credentialId));
+
+ // Get account list for this credential
+ const { addresses } = await findAddressesByPasskey({
+ client,
+ contracts,
+ passkey: {
+ credentialId: credentialIdHex,
+ originDomain,
+ },
+ });
+
+ if (addresses.length === 0) {
+ throw new Error(`No account found for credential ID: ${credentialId}`);
+ }
+
+ // Get the first account (most common case is one account per passkey)
+ const address = addresses[0];
+
+ // Get the public key for this account
+ const publicKeyCoords = await readContract(client, {
+ abi: WebAuthnValidatorAbi,
+ address: contracts.webauthnValidator,
+ functionName: "getAccountKey",
+ args: [originDomain, credentialIdHex, address],
+ });
+
+ if (!publicKeyCoords || !publicKeyCoords[0] || !publicKeyCoords[1]) {
+ throw new Error(`Account credentials not found in on-chain validator module for credential ${credentialId}`);
+ }
+
+ // Convert the public key coordinates back to COSE format
+ // The coordinates are returned as bytes32[2], we need to convert them to a COSE public key
+ // For now, we'll return them as a simple concatenated Uint8Array
+ // This matches what the legacy SDK's getPasskeySignatureFromPublicKeyBytes expects
+ const xBytes = hexToBytes(publicKeyCoords[0]);
+ const yBytes = hexToBytes(publicKeyCoords[1]);
+
+ // Create COSE-encoded public key (simplified version)
+ // This is a minimal CBOR map encoding for ES256 key
+ const coseKey = new Uint8Array([
+ 0xa5, // Map with 5 items
+ 0x01, 0x02, // kty: 2 (EC2)
+ 0x03, 0x26, // alg: -7 (ES256)
+ 0x20, 0x01, // crv: 1 (P-256)
+ 0x21, 0x58, 0x20, ...xBytes, // x: 32-byte coordinate
+ 0x22, 0x58, 0x20, ...yBytes, // y: 32-byte coordinate
+ ]);
+
+ return {
+ credentialId,
+ address,
+ passkeyPublicKey: coseKey,
+ };
+}
diff --git a/packages/sdk-4337/src/client/passkey/index.ts b/packages/sdk-4337/src/client/passkey/index.ts
index c2dc1b1dc..08c251a6b 100644
--- a/packages/sdk-4337/src/client/passkey/index.ts
+++ b/packages/sdk-4337/src/client/passkey/index.ts
@@ -9,9 +9,12 @@ export {
passkeyClientActions,
} from "./client-actions.js";
export {
+ base64urlToUint8Array,
type CreateCredentialOptions,
createWebAuthnCredential,
getPasskeyCredential,
+ getPasskeySignatureFromPublicKeyBytes,
+ getPublicKeyBytesFromPasskeySignature,
signWithPasskey,
type SignWithPasskeyOptions,
type WebAuthnCredential,
diff --git a/packages/sdk-4337/src/client/passkey/webauthn.ts b/packages/sdk-4337/src/client/passkey/webauthn.ts
index ef604f8d8..ec71ff14e 100644
--- a/packages/sdk-4337/src/client/passkey/webauthn.ts
+++ b/packages/sdk-4337/src/client/passkey/webauthn.ts
@@ -23,7 +23,7 @@ function uint8ArrayToBase64url(bytes: Uint8Array): string {
/**
* Convert base64url string to Uint8Array
*/
-function base64urlToUint8Array(base64url: string): Uint8Array {
+export function base64urlToUint8Array(base64url: string): Uint8Array {
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(base64.length + (4 - (base64.length % 4)) % 4, "=");
const binary = atob(padded);
@@ -246,6 +246,11 @@ export interface WebAuthnCredential {
*/
credentialId: Hex;
+ /**
+ * Raw credential ID (base64url encoded string)
+ */
+ credentialIdBase64url: string;
+
publicKey: {
/**
* X coordinate of P-256 public key (32 bytes, hex string with 0x prefix)
@@ -382,7 +387,7 @@ export async function createWebAuthnCredential(options: CreateCredentialOptions)
const coseKey = authenticatorData.slice(coseKeyOffset);
// Parse COSE key to extract public key coordinates
- const [xBuffer, yBuffer] = parseCOSEKey(coseKey);
+ const [xBuffer, yBuffer] = getPublicKeyBytesFromPasskeySignature(coseKey);
console.log({
credential,
@@ -391,6 +396,7 @@ export async function createWebAuthnCredential(options: CreateCredentialOptions)
return {
credentialId: bytesToHex(credId),
+ credentialIdBase64url: credential.id,
publicKey: {
x: bytesToHex(xBuffer),
y: bytesToHex(yBuffer),
@@ -489,10 +495,11 @@ function decodeValue(buffer: Uint8Array, offset: number): [number | Uint8Array,
/**
* Parse COSE key to extract P-256 public key coordinates
+ * Browser-compatible version using Uint8Array (no Node Buffer dependency)
* @param publicPasskey - CBOR-encoded COSE public key
* @returns Tuple of [x, y] coordinates as Uint8Arrays
*/
-function parseCOSEKey(publicPasskey: Uint8Array): [Uint8Array, Uint8Array] {
+export function getPublicKeyBytesFromPasskeySignature(publicPasskey: Uint8Array): [Uint8Array, Uint8Array] {
const cosePublicKey = decodeMap(publicPasskey);
const x = cosePublicKey.get(COSEKEYS.x) as Uint8Array;
const y = cosePublicKey.get(COSEKEYS.y) as Uint8Array;
@@ -508,6 +515,109 @@ function parseCOSEKey(publicPasskey: Uint8Array): [Uint8Array, Uint8Array] {
return [x, y];
}
+// ============================================================================
+// COSE/CBOR Encoding Functions
+// ============================================================================
+
+// Encode an integer in CBOR format
+function encodeInt(int: number): Uint8Array {
+ if (int >= 0 && int <= 23) {
+ return new Uint8Array([int]);
+ } else if (int >= 24 && int <= 255) {
+ return new Uint8Array([0x18, int]);
+ } else if (int >= 256 && int <= 65535) {
+ const buf = new Uint8Array(3);
+ buf[0] = 0x19;
+ buf[1] = (int >> 8) & 0xFF;
+ buf[2] = int & 0xFF;
+ return buf;
+ } else if (int < 0 && int >= -24) {
+ return new Uint8Array([0x20 - (int + 1)]);
+ } else if (int < -24 && int >= -256) {
+ return new Uint8Array([0x38, -int - 1]);
+ } else if (int < -256 && int >= -65536) {
+ const buf = new Uint8Array(3);
+ buf[0] = 0x39;
+ const value = -int - 1;
+ buf[1] = (value >> 8) & 0xFF;
+ buf[2] = value & 0xFF;
+ return buf;
+ } else {
+ throw new Error("Unsupported integer range");
+ }
+}
+
+// Encode a byte array in CBOR format
+function encodeBytes(bytes: Uint8Array): Uint8Array {
+ if (bytes.length <= 23) {
+ const result = new Uint8Array(1 + bytes.length);
+ result[0] = 0x40 + bytes.length;
+ result.set(bytes, 1);
+ return result;
+ } else if (bytes.length < 256) {
+ const result = new Uint8Array(2 + bytes.length);
+ result[0] = 0x58;
+ result[1] = bytes.length;
+ result.set(bytes, 2);
+ return result;
+ } else {
+ throw new Error("Unsupported byte array length");
+ }
+}
+
+// Encode a map in CBOR format
+function encodeMap(map: COSEPublicKeyMap): Uint8Array {
+ const encodedItems: Uint8Array[] = [];
+
+ // CBOR map header
+ const mapHeader = 0xA0 | map.size;
+ encodedItems.push(new Uint8Array([mapHeader]));
+
+ map.forEach((value, key) => {
+ // Encode the key
+ encodedItems.push(encodeInt(key));
+
+ // Encode the value based on its type
+ if (value instanceof Uint8Array) {
+ encodedItems.push(encodeBytes(value));
+ } else {
+ encodedItems.push(encodeInt(value));
+ }
+ });
+
+ // Concatenate all encoded items
+ const totalLength = encodedItems.reduce((sum, item) => sum + item.length, 0);
+ const result = new Uint8Array(totalLength);
+ let offset = 0;
+ for (const item of encodedItems) {
+ result.set(item, offset);
+ offset += item.length;
+ }
+ return result;
+}
+
+/**
+ * Encodes x,y hex coordinates into a CBOR-encoded COSE public key format.
+ * Browser-compatible version using Uint8Array (no Node Buffer dependency)
+ * This is the inverse of getPublicKeyBytesFromPasskeySignature.
+ * @param coordinates - Tuple of [x, y] coordinates as hex strings
+ * @returns CBOR-encoded COSE public key as Uint8Array
+ */
+export function getPasskeySignatureFromPublicKeyBytes(coordinates: readonly [Hex, Hex]): Uint8Array {
+ const [xHex, yHex] = coordinates;
+ const x = hexToBytes(xHex);
+ const y = hexToBytes(yHex);
+
+ const cosePublicKey: COSEPublicKeyMap = new Map();
+ cosePublicKey.set(COSEKEYS.kty, 2); // Type 2 for EC keys
+ cosePublicKey.set(COSEKEYS.alg, -7); // -7 for ES256 algorithm
+ cosePublicKey.set(COSEKEYS.crv, 1); // Curve ID (1 for P-256)
+ cosePublicKey.set(COSEKEYS.x, x);
+ cosePublicKey.set(COSEKEYS.y, y);
+
+ return encodeMap(cosePublicKey);
+}
+
export async function getPasskeyCredential() {
const credential = await navigator.credentials.get({
publicKey: {
diff --git a/packages/sdk-4337/src/index.ts b/packages/sdk-4337/src/index.ts
index 5c7aaa360..fa3cb11b2 100644
--- a/packages/sdk-4337/src/index.ts
+++ b/packages/sdk-4337/src/index.ts
@@ -9,3 +9,4 @@ export * from "./errors/index.js";
// Re-export actions
export * from "./actions/sendUserOperation.js";
+export * from "./client/actions/index.js";
diff --git a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-ffi-web/src/account/modular_smart_account/guardian.rs b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-ffi-web/src/account/modular_smart_account/guardian.rs
index 85274c48a..4fc58fbdc 100644
--- a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-ffi-web/src/account/modular_smart_account/guardian.rs
+++ b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-ffi-web/src/account/modular_smart_account/guardian.rs
@@ -1,4 +1,5 @@
pub mod accept;
+pub mod encode;
pub mod list;
pub mod propose;
pub mod recovery;
diff --git a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-ffi-web/src/account/modular_smart_account/guardian/encode.rs b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-ffi-web/src/account/modular_smart_account/guardian/encode.rs
new file mode 100644
index 000000000..62f1da9df
--- /dev/null
+++ b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-ffi-web/src/account/modular_smart_account/guardian/encode.rs
@@ -0,0 +1,88 @@
+use alloy::primitives::Address;
+use wasm_bindgen::prelude::*;
+use zksync_sso_erc4337_core::erc4337::account::modular_smart_account::guardian::{
+ propose::propose_guardian_call_data,
+ remove::remove_guardian_call_data,
+};
+
+/// Encode the call data for proposing a guardian (no signing, just encoding)
+///
+/// Returns the complete encoded call data ready to be sent via account.execute()
+/// This includes both the mode and execution calldata in the format expected by ERC-7579.
+///
+/// # Parameters
+/// * `guardian_executor` - Address of the GuardianExecutor contract
+/// * `new_guardian` - Address of the guardian to propose
+///
+/// # Returns
+/// Hex-encoded call data (0x-prefixed) for account.execute(mode, executionCalldata)
+/// The returned data is a complete executeCall that can be sent directly to the account
+#[wasm_bindgen]
+pub fn encode_propose_guardian_call_data(
+ guardian_executor: String,
+ new_guardian: String,
+) -> Result {
+ // Parse addresses
+ let guardian_executor_addr =
+ guardian_executor.parse::().map_err(|e| {
+ JsValue::from_str(&format!(
+ "Invalid guardian executor address: {}",
+ e
+ ))
+ })?;
+
+ let new_guardian_addr = new_guardian.parse::().map_err(|e| {
+ JsValue::from_str(&format!("Invalid new guardian address: {}", e))
+ })?;
+
+ // Get the encoded call data
+ let call_data =
+ propose_guardian_call_data(new_guardian_addr, guardian_executor_addr);
+
+ // Return as hex string
+ Ok(format!("0x{}", hex::encode(call_data)))
+}
+
+/// Encode the call data for removing a guardian (no signing, just encoding)
+///
+/// Returns the complete encoded call data ready to be sent via account.execute()
+/// This includes both the mode and execution calldata in the format expected by ERC-7579.
+///
+/// # Parameters
+/// * `guardian_executor` - Address of the GuardianExecutor contract
+/// * `guardian_to_remove` - Address of the guardian to remove
+///
+/// # Returns
+/// Hex-encoded call data (0x-prefixed) for account.execute(mode, executionCalldata)
+/// The returned data is a complete executeCall that can be sent directly to the account
+#[wasm_bindgen]
+pub fn encode_remove_guardian_call_data(
+ guardian_executor: String,
+ guardian_to_remove: String,
+) -> Result {
+ // Parse addresses
+ let guardian_executor_addr =
+ guardian_executor.parse::().map_err(|e| {
+ JsValue::from_str(&format!(
+ "Invalid guardian executor address: {}",
+ e
+ ))
+ })?;
+
+ let guardian_to_remove_addr =
+ guardian_to_remove.parse::().map_err(|e| {
+ JsValue::from_str(&format!(
+ "Invalid guardian to remove address: {}",
+ e
+ ))
+ })?;
+
+ // Get the encoded call data
+ let call_data = remove_guardian_call_data(
+ guardian_to_remove_addr,
+ guardian_executor_addr,
+ );
+
+ // Return as hex string
+ Ok(format!("0x{}", hex::encode(call_data)))
+}
diff --git a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-ffi-web/src/lib.rs b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-ffi-web/src/lib.rs
index 594ab6569..cd61835f3 100644
--- a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-ffi-web/src/lib.rs
+++ b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-ffi-web/src/lib.rs
@@ -2519,6 +2519,7 @@ pub fn encode_deploy_account_call_data(
passkey_payload: Option,
webauthn_validator_address: Option,
session_validator_address: Option,
+ executor_modules: Option>,
) -> Result {
use zksync_sso_erc4337_core::erc4337::account::modular_smart_account::{
deploy::{
@@ -2633,11 +2634,34 @@ pub fn encode_deploy_account_call_data(
None => None,
};
+ // Parse executor modules if provided
+ let executor_modules_core = match executor_modules {
+ Some(addresses) => {
+ let mut parsed_addresses = Vec::new();
+ for addr_str in addresses {
+ match addr_str.parse::() {
+ Ok(addr) => {
+ parsed_addresses.push(addr);
+ }
+ Err(e) => {
+ return Err(JsValue::from_str(&format!(
+ "Invalid executor module address '{}': {}",
+ addr_str, e
+ )));
+ }
+ }
+ }
+ Some(parsed_addresses)
+ }
+ None => None,
+ };
+
// Create init data using the same logic as deploy.rs
let init_data = create_init_data_for_deployment(
eoa_signers_core,
webauthn_signer_core,
session_validator_core,
+ executor_modules_core,
);
// Encode the call
@@ -2704,6 +2728,7 @@ fn create_init_data_for_deployment(
eoa_signers: Option,
webauthn_signer: Option,
session_validator: Option,
+ executor_modules: Option>,
) -> Bytes {
sol! {
struct SignersParams {
@@ -2737,6 +2762,14 @@ fn create_init_data_for_deployment(
data.push(Bytes::new()); // Empty bytes for session validator
}
+ // Add executor modules if provided (no initialization data needed)
+ if let Some(executor_addrs) = executor_modules {
+ for executor_addr in executor_addrs {
+ modules.push(executor_addr);
+ data.push(Bytes::new()); // Empty bytes for executor modules
+ }
+ }
+
// Create initializeAccount call
initialize_account_call_data_core(modules, data)
diff --git a/packages/sdk-platforms/web/src/bundler.ts b/packages/sdk-platforms/web/src/bundler.ts
index 28e77b461..225c9c65e 100644
--- a/packages/sdk-platforms/web/src/bundler.ts
+++ b/packages/sdk-platforms/web/src/bundler.ts
@@ -56,6 +56,19 @@ export const {
// Core functions for sending user operations with paymaster support
PaymasterParams, // Paymaster configuration (address, data, gas limits)
send_user_operation, // Send UserOperation with optional paymaster sponsorship
+
+ // ===== GUARDIAN MANAGEMENT =====
+ // Functions for managing guardian recovery system
+ propose_guardian_wasm, // Propose a new guardian for smart account
+ accept_guardian_wasm, // Accept a proposed guardian role
+ remove_guardian_wasm, // Remove an active guardian
+ get_guardians_list_wasm, // Get list of guardians for an account
+ get_guardian_status_wasm, // Get status of a specific guardian
+ initialize_recovery_wasm, // Initialize guardian-based recovery process
+
+ // Guardian encoding functions (for passkey-based accounts)
+ encode_propose_guardian_call_data, // Encode propose guardian call (no signing)
+ encode_remove_guardian_call_data, // Encode remove guardian call (no signing)
} = wasm;
// Initialize WASM module
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts
index d00a013df..541b0b8e5 100644
--- a/packages/sdk/src/index.ts
+++ b/packages/sdk/src/index.ts
@@ -1,3 +1,4 @@
export type { AppMetadata, ProviderInterface } from "./client-auth-server/interface.js";
export type { SessionPreferences } from "./client-auth-server/session/index.js";
export { WalletProvider, type WalletProviderConstructorOptions } from "./client-auth-server/WalletProvider.js";
+export { getPublicKeyBytesFromPasskeySignature } from "./utils/passkey.js";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4f8b8f799..eeffc9a6c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -405,6 +405,9 @@ importers:
specifier: ^3.24.1
version: 3.24.1
devDependencies:
+ '@playwright/test':
+ specifier: ^1.48.2
+ version: 1.57.0
'@walletconnect/types':
specifier: ^2.19.1
version: 2.19.1(ioredis@5.4.1)
@@ -4508,6 +4511,11 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ '@playwright/test@1.57.0':
+ resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@polka/url@1.0.0-next.28':
resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==}
@@ -10708,11 +10716,21 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ playwright-core@1.57.0:
+ resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==}
+ engines: {node: '>=18'}
+ hasBin: true
+
playwright@1.48.1:
resolution: {integrity: sha512-j8CiHW/V6HxmbntOfyB4+T/uk08tBy6ph0MpBXwuoofkSnLmlfdYNNkFTYD6ofzzlSqLA1fwH4vwvVFvJgLN0w==}
engines: {node: '>=18'}
hasBin: true
+ playwright@1.57.0:
+ resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==}
+ engines: {node: '>=18'}
+ hasBin: true
+
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
@@ -18999,6 +19017,10 @@ snapshots:
dependencies:
playwright: 1.48.1
+ '@playwright/test@1.57.0':
+ dependencies:
+ playwright: 1.57.0
+
'@polka/url@1.0.0-next.28': {}
'@prisma/instrumentation@6.11.1(@opentelemetry/api@1.9.0)':
@@ -28794,12 +28816,20 @@ snapshots:
playwright-core@1.56.1: {}
+ playwright-core@1.57.0: {}
+
playwright@1.48.1:
dependencies:
playwright-core: 1.48.1
optionalDependencies:
fsevents: 2.3.2
+ playwright@1.57.0:
+ dependencies:
+ playwright-core: 1.57.0
+ optionalDependencies:
+ fsevents: 2.3.2
+
pluralize@8.0.0: {}
pngjs@5.0.0: {}