From b9afdc3dc97d24b92b27cbb4fd893c8d21020d66 Mon Sep 17 00:00:00 2001 From: Nicolas Villanueva <1890113+MexicanAce@users.noreply.github.com> Date: Sun, 22 Dec 2024 04:16:36 -0800 Subject: [PATCH] fix: auth server and sdk when not using sessions (#45) --- .github/workflows/ci.yml | 11 + .gitignore | 4 + README.md | 14 +- examples/demo-app/.gitignore | 1 + examples/demo-app/package.json | 5 +- examples/demo-app/pages/index.vue | 84 +++++- examples/demo-app/project.json | 22 +- .../smart-contracts/DemoPaymaster.sol | 65 ++++ .../demo-app/tests/create-account.spec.ts | 282 +++++++++++++++++- .../auth-server/components/views/Auth.vue | 2 +- .../views/confirmation/RequestSession.vue | 2 +- packages/sdk/src/client/passkey/client.ts | 4 +- .../src/client/passkey/decorators/wallet.ts | 52 ++++ pnpm-lock.yaml | 77 ++++- 14 files changed, 575 insertions(+), 50 deletions(-) create mode 100644 examples/demo-app/smart-contracts/DemoPaymaster.sol create mode 100644 packages/sdk/src/client/passkey/decorators/wallet.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 149d244..dc8a890 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,17 @@ jobs: run: pnpm run deploy working-directory: packages/contracts + - name: Install zksync-foundry + run: | + wget -qc https://github.com/matter-labs/foundry-zksync/releases/download/nightly/foundry_nightly_linux_amd64.tar.gz -O - | tar -xz + ./forge -V && ./cast -V + sudo mv ./forge /usr/local/bin/ + sudo mv ./cast /usr/local/bin/ + forge -V && cast -V + + - name: Deploy Demo-App contracts + run: pnpm nx deploy-contracts demo-app + # Run E2E tests - name: Install Playwright Chromium Browser run: pnpm exec playwright install chromium diff --git a/.gitignore b/.gitignore index 1411f3c..2425315 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ node_modules/ # era-test-node era_test_node.log +anvil-zksync.log +foundryup-zksync +cache/ +zkout/ package-lock.json yarn.lock diff --git a/README.md b/README.md index 832045b..b29dd57 100644 --- a/README.md +++ b/README.md @@ -125,13 +125,19 @@ This monorepo is comprised of the following packages, products, and examples: [workspace protocol](https://pnpm.io/workspaces#workspace-protocol-workspace) to link SDK in the new folder. -3. Start a local node: +3. Install `foundry-zksync`: + + ```bash + curl -L https://raw.githubusercontent.com/matter-labs/foundry-zksync/main/install-foundry-zksync | bash + ``` + +4. Start a local node: ```bash npx zksync-cli dev start ``` -4. Compile and deploy contracts to the local node: +5. Compile and deploy contracts to the local node: ```bash # Compile and deploy contracts @@ -140,11 +146,9 @@ This monorepo is comprised of the following packages, products, and examples: pnpm run deploy ``` -5. Start the demo application: +6. Start the demo application: ```bash - # Go back to root folder to start demo app - cd ../.. pnpm nx dev demo-app ``` diff --git a/examples/demo-app/.gitignore b/examples/demo-app/.gitignore index 4a7f73a..5494e94 100644 --- a/examples/demo-app/.gitignore +++ b/examples/demo-app/.gitignore @@ -22,3 +22,4 @@ logs .env .env.* !.env.example +forge-output.json diff --git a/examples/demo-app/package.json b/examples/demo-app/package.json index 954d1ec..b748cce 100644 --- a/examples/demo-app/package.json +++ b/examples/demo-app/package.json @@ -7,6 +7,7 @@ "postinstall": "nuxt prepare" }, "dependencies": { + "@matterlabs/zksync-contracts": "^0.6.1", "@nuxtjs/google-fonts": "^3.2.0", "@pinia/nuxt": "^0.5.5", "@simplewebauthn/browser": "^10.0.0", @@ -19,8 +20,8 @@ "viem": "2.21.14", "vue": "^3.4.21", "wagmi": "^2.12.17", - "zksync-sso": "workspace:*", - "zksync-ethers": "^6.15.0" + "zksync-ethers": "^6.15.0", + "zksync-sso": "workspace:*" }, "devDependencies": { "@nuxt/eslint": "^0.5.7", diff --git a/examples/demo-app/pages/index.vue b/examples/demo-app/pages/index.vue index 0cb7722..a363b22 100644 --- a/examples/demo-app/pages/index.vue +++ b/examples/demo-app/pages/index.vue @@ -4,11 +4,18 @@ ZKsync SSO Demo +
+
{ transport: http(), }); - await richClient.sendTransaction({ + let transactionHash = await richClient.sendTransaction({ to: address.value, value: parseEther("1"), }); + // FIXME: When not using sessions, sendTransaction returns a map and not a string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((transactionHash as any).value !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transactionHash = (transactionHash as any).value; + } + + await waitForTransactionReceipt(wagmiConfig, { + hash: transactionHash, + }); }; watchAccount(wagmiConfig, { @@ -118,11 +148,11 @@ watch(address, async () => { balance.value = currentBalance; }, { immediate: true }); -const connectWallet = async () => { +const connectWallet = async (useSession: boolean) => { try { errorMessage.value = ""; connect(wagmiConfig, { - connector: zksyncConnector, + connector: useSession ? zksyncConnectorWithSession : zksyncConnector, chainId: chain.id, }); } catch (error) { @@ -133,25 +163,45 @@ const connectWallet = async () => { }; const disconnectWallet = async () => { + errorMessage.value = ""; await disconnect(wagmiConfig); }; -const sendTokens = async () => { +const sendTokens = async (usePaymaster: boolean) => { if (!address.value) return; errorMessage.value = ""; isSendingEth.value = true; try { - const transactionHash = await sendTransaction(wagmiConfig, { - to: testTransferTarget, - value: parseEther("0.1"), - }); + let transactionHash; + if (usePaymaster) { + transactionHash = await sendTransaction(wagmiConfig, { + to: testTransferTarget, + value: parseEther("0.1"), + paymaster: PaymasterContract.deployedTo as `0x${string}`, + paymasterInput: getGeneralPaymasterInput({ innerInput: "0x" }), + }); + } else { + transactionHash = await sendTransaction(wagmiConfig, { + to: testTransferTarget, + value: parseEther("0.1"), + }); + } + + // FIXME: When not using sessions, sendTransaction returns a map and not a string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((transactionHash as any).value !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transactionHash = (transactionHash as any).value; + } + + const receipt = await waitForTransactionReceipt(wagmiConfig, { + hash: transactionHash, + }); balance.value = await getBalance(wagmiConfig, { address: address.value, }); - - const receipt = await waitForTransactionReceipt(wagmiConfig, { hash: transactionHash }); if (receipt.status === "reverted") throw new Error("Transaction reverted"); } catch (error) { // eslint-disable-next-line no-console @@ -162,6 +212,10 @@ const sendTokens = async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any transactionFailureDetails = (error as any).cause?.cause?.data?.originalError?.cause?.details; } + if (!transactionFailureDetails) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + transactionFailureDetails = (error as any).cause?.details; + } if (transactionFailureDetails) { errorMessage.value = transactionFailureDetails; diff --git a/examples/demo-app/project.json b/examples/demo-app/project.json index 40454eb..95f48b6 100644 --- a/examples/demo-app/project.json +++ b/examples/demo-app/project.json @@ -17,7 +17,8 @@ "prefix": "Demo-App:" } ] - } + }, + "dependsOn": ["deploy-contracts"] }, "build": { "executor": "nx:run-commands", @@ -28,8 +29,24 @@ "command": "pnpm nuxt generate" } ] + }, + "dependsOn": ["deploy-contracts"] + }, + "build-contracts": { + "executor": "nx:run-commands", + "options": { + "cwd": "examples/demo-app", + "command": "forge build smart-contracts/DemoPaymaster.sol --root . --zksync" } }, + "deploy-contracts": { + "executor": "nx:run-commands", + "options": { + "cwd": "examples/demo-app", + "command": "forge create smart-contracts/DemoPaymaster.sol:DemoPaymaster --private-key 0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110 --rpc-url http://localhost:8011 --root . --chain 260 --zksync --json 2>&1 | sed -n 's/.*\\({.*}\\).*/\\1/p' > forge-output.json && ADDRESS=$(sed -n 's/.*\"deployedTo\":\"\\([^\"]*\\)\".*/\\1/p' forge-output.json) && echo $ADDRESS && cast send --private-key 0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110 $ADDRESS --rpc-url http://localhost:8011 --value 0.1ether" + }, + "dependsOn": ["build-contracts"] + }, "build:local": { "executor": "nx:run-commands", "options": { @@ -60,7 +77,8 @@ "options": { "cwd": "examples/demo-app", "command": "pnpm exec playwright install chromium" - } + }, + "dependsOn": ["deploy-contracts"] }, "e2e": { "executor": "nx:run-commands", diff --git a/examples/demo-app/smart-contracts/DemoPaymaster.sol b/examples/demo-app/smart-contracts/DemoPaymaster.sol new file mode 100644 index 0000000..3a549c6 --- /dev/null +++ b/examples/demo-app/smart-contracts/DemoPaymaster.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +/// !!! !!! +/// !!! THIS IS FOR DEMO PURPOSES ONLY !!! +/// !!! !!! +/// !!! DO NOT COPY THIS PAYMASTER !!! +/// !!! FOR PRODUCTION APPLICATIONS !!! +/// !!! !!! +/// !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +import { IPaymaster, ExecutionResult, PAYMASTER_VALIDATION_SUCCESS_MAGIC } from "@matterlabs/zksync-contracts/l2/system-contracts/interfaces/IPaymaster.sol"; +import { IPaymasterFlow } from "@matterlabs/zksync-contracts/l2/system-contracts/interfaces/IPaymasterFlow.sol"; +import { TransactionHelper, Transaction } from "@matterlabs/zksync-contracts/l2/system-contracts/libraries/TransactionHelper.sol"; + +import "@matterlabs/zksync-contracts/l2/system-contracts/Constants.sol"; + +/// @author Matter Labs +/// @notice DO NOT USE THIS FOR PRODUCTION. This contract does not include any validations other than using the paymaster general flow. +contract DemoPaymaster is IPaymaster { + modifier onlyBootloader() { + require(msg.sender == BOOTLOADER_FORMAL_ADDRESS, "Only bootloader can call this method"); + // Continue execution if called from the bootloader. + _; + } + + function validateAndPayForPaymasterTransaction( + bytes32, + bytes32, + Transaction calldata _transaction + ) external payable onlyBootloader returns (bytes4 magic, bytes memory context) { + // By default we consider the transaction as accepted. + magic = PAYMASTER_VALIDATION_SUCCESS_MAGIC; + require(_transaction.paymasterInput.length >= 4, "The standard paymaster input must be at least 4 bytes long"); + + bytes4 paymasterInputSelector = bytes4(_transaction.paymasterInput[0:4]); + require(paymasterInputSelector == IPaymasterFlow.general.selector, "Unsupported paymaster flow"); + + // Note, that while the minimal amount of ETH needed is tx.gasPrice * tx.gasLimit, + // neither paymaster nor account are allowed to access this context variable. + uint256 requiredETH = _transaction.gasLimit * _transaction.maxFeePerGas; + + // The bootloader never returns any data, so it can safely be ignored here. + (bool success, ) = payable(BOOTLOADER_FORMAL_ADDRESS).call{ value: requiredETH }(""); + require(success, "Failed to transfer tx fee to the Bootloader. Paymaster balance might not be enough."); + } + + function postTransaction( + bytes calldata _context, + Transaction calldata _transaction, + bytes32, + bytes32, + ExecutionResult _txResult, + uint256 _maxRefundedGas + ) external payable override onlyBootloader {} + + function withdraw(address payable _to) external { + uint256 balance = address(this).balance; + (bool success, ) = _to.call{ value: balance }(""); + require(success, "Failed to withdraw funds from paymaster."); + } + + receive() external payable {} +} diff --git a/examples/demo-app/tests/create-account.spec.ts b/examples/demo-app/tests/create-account.spec.ts index 5adb422..f2c2399 100644 --- a/examples/demo-app/tests/create-account.spec.ts +++ b/examples/demo-app/tests/create-account.spec.ts @@ -44,9 +44,9 @@ test.beforeEach(async ({ page }) => { await expect(page.getByText("ZKsync SSO Demo")).toBeVisible(); }); -test("Create account, session key, and send ETH", async ({ page }) => { +test("Create account w/ session and send ETH", async ({ page }) => { // Click the Connect button - await page.getByRole("button", { name: "Connect" }).click(); + await page.getByRole("button", { name: "Connect w/ Session", exact: true }).click(); // Ensure popup is displayed await page.waitForTimeout(2000); @@ -64,7 +64,68 @@ test("Create account, session key, and send ETH", async ({ page }) => { // NOTE: This needs to be done for every page of every test that uses WebAuthn const client = await popup.context().newCDPSession(popup); await client.send("WebAuthn.enable"); - const result = await client.send("WebAuthn.addVirtualAuthenticator", { + await client.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + transport: "usb", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + + // Click Sign Up + await popup.getByTestId("signup").click(); + + // Add session + await expect(popup.getByText("Authorize ZKsync SSO Demo")).toBeVisible(); + await expect(popup.getByText("Act on your behalf")).toBeVisible(); + await expect(popup.getByText("Expires tomorrow")).toBeVisible(); + await expect(popup.getByText("Permissions")).toBeVisible(); + await popup.getByTestId("connect").click(); + + // Waits for session to complete and popup to close + await page.waitForTimeout(2000); + + // Check address/balance is shown + await expect(page.getByText("Disconnect")).toBeVisible(); + await expect(page.getByText("Balance:")).toBeVisible(); + const startBalance = +(await page.getByText("Balance:").innerText()) + .replace("Balance: ", "") + .replace(" ETH", ""); + + // Send some eth + await page.getByRole("button", { name: "Send 0.1 ETH", exact: true }).click(); + await expect(page.getByRole("button", { name: "Send 0.1 ETH", exact: true })).toBeEnabled(); + const endBalance = +(await page.getByText("Balance:").innerText()) + .replace("Balance: ", "") + .replace(" ETH", ""); + await expect(startBalance, "Balance after transfer should be ~0.1 ETH less") + .toBeGreaterThan(endBalance + 0.1); +}); + +test("Create account w/ session and send ETH w/ paymaster", async ({ page }) => { + // Click the Connect button + await page.getByRole("button", { name: "Connect w/ Session", exact: true }).click(); + + // Ensure popup is displayed + await page.waitForTimeout(2000); + const popup = page.context().pages()[1]; + await expect(popup.getByText("Connect to")).toBeVisible(); + popup.on("console", (msg) => { + if (msg.type() === "error") + console.log(`Auth server error console: "${msg.text()}"`); + }); + popup.on("pageerror", (exception) => { + console.log(`Auth server uncaught exception: "${exception}"`); + }); + + // Setup webauthn a Chrome Devtools Protocol session + // NOTE: This needs to be done for every page of every test that uses WebAuthn + const client = await popup.context().newCDPSession(popup); + await client.send("WebAuthn.enable"); + await client.send("WebAuthn.addVirtualAuthenticator", { options: { protocol: "ctap2", transport: "usb", @@ -74,8 +135,6 @@ test("Create account, session key, and send ETH", async ({ page }) => { automaticPresenceSimulation: true, }, }); - const authenticatorId = result.authenticatorId; - console.log(`WebAuthn Authenticator ID: ${authenticatorId}`); // Click Sign Up await popup.getByTestId("signup").click(); @@ -92,20 +151,221 @@ test("Create account, session key, and send ETH", async ({ page }) => { // Check address/balance is shown await expect(page.getByText("Disconnect")).toBeVisible(); - const address = (await page.getByText("Connected Address:").innerText()) - .replace("Connected Address: ", ""); - console.log(`Public Address: ${address}`); + await expect(page.getByText("Balance:")).toBeVisible(); + const startBalance = +(await page.getByText("Balance:").innerText()) + .replace("Balance: ", "") + .replace(" ETH", ""); + + // Send some eth w/ paymaster + await page.getByRole("button", { name: "Send 0.1 ETH w/ Paymaster", exact: true }).click(); + await expect(page.getByRole("button", { name: "Send 0.1 ETH w/ Paymaster", exact: true })).toBeEnabled(); + const endBalance = +(await page.getByText("Balance:").innerText()) + .replace("Balance: ", "") + .replace(" ETH", ""); + await expect(startBalance, "Balance after transfer should be 0.1 ETH less (no fees)") + .toEqual(endBalance + 0.1); +}); + +test("Create passkey account and send ETH", async ({ page }) => { + // Click the Connect button + await page.getByRole("button", { name: "Connect", exact: true }).click(); + + // Ensure popup is displayed + await page.waitForTimeout(2000); + let popup = page.context().pages()[1]; + await expect(popup.getByText("Connect to")).toBeVisible(); + popup.on("console", (msg) => { + if (msg.type() === "error") + console.log(`Auth server error console: "${msg.text()}"`); + }); + popup.on("pageerror", (exception) => { + console.log(`Auth server uncaught exception: "${exception}"`); + }); + + // Setup webauthn a Chrome Devtools Protocol session + // NOTE: This needs to be done for every page of every test that uses WebAuthn + let client = await popup.context().newCDPSession(popup); + await client.send("WebAuthn.enable"); + await client.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + transport: "usb", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + let newCredential = null; + client.on("WebAuthn.credentialAdded", (credentialAdded) => { + console.log("New Passkey credential added"); + console.log(`Authenticator ID: ${credentialAdded.authenticatorId}`); + console.log(`Credential: ${credentialAdded.credential}`); + newCredential = credentialAdded.credential; + }); + + // Click Sign Up + await popup.getByTestId("signup").click(); + + // Confirm access to your account + await expect(popup.getByText("Connect to ZKsync SSO Demo")).toBeVisible(); + await expect(popup.getByText("localhost:3004")).toBeVisible(); + await expect(popup.getByText("Let it see your address, balance and activity")).toBeVisible(); + await popup.getByTestId("connect").click(); + + // Waits for session to complete and popup to close + await page.waitForTimeout(2000); + + // Check address/balance is shown + await expect(page.getByText("Disconnect")).toBeVisible(); await expect(page.getByText("Balance:")).toBeVisible(); const startBalance = +(await page.getByText("Balance:").innerText()) .replace("Balance: ", "") .replace(" ETH", ""); // Send some eth - await page.getByRole("button", { name: "Send 0.1 ETH" }).click(); - await expect(page.getByRole("button", { name: "Send 0.1 ETH" })).toBeEnabled(); + await page.getByRole("button", { name: "Send 0.1 ETH", exact: true }).click(); + + // Wait for Auth Server to pop back up + await page.waitForTimeout(2000); + popup = page.context().pages()[1]; + + // We need to recreate the virtual authenticator to match the previous one + client = await popup.context().newCDPSession(popup); + 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 expect(newCredential).not.toBeNull(); + await client.send("WebAuthn.addCredential", { + authenticatorId: result.authenticatorId, + credential: newCredential!, + }); + + // Confirm the transfer + await expect(popup.getByText("-0.1")).toBeVisible(); + await expect(popup.getByText("Sending to")).toBeVisible(); + await expect(popup.getByText("0x55b...4A6")).toBeVisible(); + await expect(popup.getByText("Fees")).toBeVisible(); + await popup.getByTestId("confirm").click(); + + // Wait for confirmation to complete and popup to close + await page.waitForTimeout(2000); + + // Confirm transfer completed and balance updated + await expect(page.getByRole("button", { name: "Send 0.1 ETH", exact: true })).toBeEnabled(); const endBalance = +(await page.getByText("Balance:").innerText()) .replace("Balance: ", "") .replace(" ETH", ""); await expect(startBalance, "Balance after transfer should be ~0.1 ETH less") - .toBeGreaterThanOrEqual(endBalance + 0.1); + .toBeGreaterThan(endBalance + 0.1); +}); + +test("Create passkey account and send ETH w/ paymaster", async ({ page }) => { + // Click the Connect button + await page.getByRole("button", { name: "Connect", exact: true }).click(); + + // Ensure popup is displayed + await page.waitForTimeout(2000); + let popup = page.context().pages()[1]; + await expect(popup.getByText("Connect to")).toBeVisible(); + popup.on("console", (msg) => { + if (msg.type() === "error") + console.log(`Auth server error console: "${msg.text()}"`); + }); + popup.on("pageerror", (exception) => { + console.log(`Auth server uncaught exception: "${exception}"`); + }); + + // Setup webauthn a Chrome Devtools Protocol session + // NOTE: This needs to be done for every page of every test that uses WebAuthn + let client = await popup.context().newCDPSession(popup); + await client.send("WebAuthn.enable"); + await client.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + transport: "usb", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + let newCredential = null; + client.on("WebAuthn.credentialAdded", (credentialAdded) => { + console.log("New Passkey credential added"); + console.log(`Authenticator ID: ${credentialAdded.authenticatorId}`); + console.log(`Credential: ${credentialAdded.credential}`); + newCredential = credentialAdded.credential; + }); + + // Click Sign Up + await popup.getByTestId("signup").click(); + + // Confirm access to your account + await expect(popup.getByText("Connect to ZKsync SSO Demo")).toBeVisible(); + await expect(popup.getByText("localhost:3004")).toBeVisible(); + await expect(popup.getByText("Let it see your address, balance and activity")).toBeVisible(); + await popup.getByTestId("connect").click(); + + // Waits for session to complete and popup to close + await page.waitForTimeout(2000); + + // Check address/balance is shown + await expect(page.getByText("Disconnect")).toBeVisible(); + await expect(page.getByText("Balance:")).toBeVisible(); + const startBalance = +(await page.getByText("Balance:").innerText()) + .replace("Balance: ", "") + .replace(" ETH", ""); + + // Send some eth w/ paymaster + await page.getByRole("button", { name: "Send 0.1 ETH w/ Paymaster", exact: true }).click(); + + // Wait for Auth Server to pop back up + await page.waitForTimeout(2000); + popup = page.context().pages()[1]; + + // We need to recreate the virtual authenticator to match the previous one + client = await popup.context().newCDPSession(popup); + 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 expect(newCredential).not.toBeNull(); + await client.send("WebAuthn.addCredential", { + authenticatorId: result.authenticatorId, + credential: newCredential!, + }); + + // Confirm the transfer + await expect(popup.getByText("-0.1")).toBeVisible(); + await expect(popup.getByText("Sending to")).toBeVisible(); + await expect(popup.getByText("0x55b...4A6")).toBeVisible(); + await expect(popup.getByText("Fees")).toBeVisible(); + await popup.getByTestId("confirm").click(); + + // Wait for confirmation to complete and popup to close + await page.waitForTimeout(2000); + + // Confirm transfer completed and balance updated + await expect(page.getByRole("button", { name: "Send 0.1 ETH w/ Paymaster", exact: true })).toBeEnabled(); + const endBalance = +(await page.getByText("Balance:").innerText()) + .replace("Balance: ", "") + .replace(" ETH", ""); + await expect(startBalance, "Balance after transfer should be 0.1 ETH less (no fees)") + .toEqual(endBalance + 0.1); }); diff --git a/packages/auth-server/components/views/Auth.vue b/packages/auth-server/components/views/Auth.vue index 2497802..4107724 100644 --- a/packages/auth-server/components/views/Auth.vue +++ b/packages/auth-server/components/views/Auth.vue @@ -66,7 +66,7 @@ const registerAccount = async () => { if (!session.value) { // no session defined await createAccount(); - if (!createAccountError) { + if (!createAccountError.value) { navigateTo("/confirm/connect"); } } else { diff --git a/packages/auth-server/components/views/confirmation/RequestSession.vue b/packages/auth-server/components/views/confirmation/RequestSession.vue index 76d8679..e20b800 100644 --- a/packages/auth-server/components/views/confirmation/RequestSession.vue +++ b/packages/auth-server/components/views/confirmation/RequestSession.vue @@ -65,7 +65,7 @@ data-testid="connect" @click="confirmConnection()" > - Connect + {{ isLoggedIn ? 'Connect' : 'Create' }}
diff --git a/packages/sdk/src/client/passkey/client.ts b/packages/sdk/src/client/passkey/client.ts index 954dafd..05c38fb 100644 --- a/packages/sdk/src/client/passkey/client.ts +++ b/packages/sdk/src/client/passkey/client.ts @@ -5,6 +5,7 @@ import { passkeyHashSignatureResponseFormat } from "../../utils/passkey.js"; import { toPasskeyAccount } from "./account.js"; import { requestPasskeyAuthentication } from "./actions/passkey.js"; import { type ZksyncSsoPasskeyActions, zksyncSsoPasskeyActions } from "./decorators/passkey.js"; +import { zksyncSsoPasskeyWalletActions } from "./decorators/wallet.js"; export function createZksyncPasskeyClient< transport extends Transport, @@ -47,7 +48,8 @@ export function createZksyncPasskeyClient< .extend(publicActions) .extend(walletActions) .extend(eip712WalletActions()) - .extend(zksyncSsoPasskeyActions); + .extend(zksyncSsoPasskeyActions) + .extend(zksyncSsoPasskeyWalletActions); return client; } diff --git a/packages/sdk/src/client/passkey/decorators/wallet.ts b/packages/sdk/src/client/passkey/decorators/wallet.ts new file mode 100644 index 0000000..066d824 --- /dev/null +++ b/packages/sdk/src/client/passkey/decorators/wallet.ts @@ -0,0 +1,52 @@ +import { type Account, bytesToHex, type Chain, formatTransaction, type Transport, type WalletActions } from "viem"; +import { deployContract, getAddresses, getChainId, sendRawTransaction, signMessage, signTypedData, writeContract } from "viem/actions"; +import { signTransaction, type ZksyncEip712Meta } from "viem/zksync"; + +import { sendEip712Transaction } from "../../session/actions/sendEip712Transaction.js"; +import type { ClientWithZksyncSsoPasskeyData } from "../client.js"; + +export type ZksyncSsoPasskeyWalletActions = Omit< + WalletActions, "addChain" | "getPermissions" | "requestAddresses" | "requestPermissions" | "switchChain" | "watchAsset" | "prepareTransactionRequest" +>; + +export function zksyncSsoPasskeyWalletActions< + transport extends Transport, + chain extends Chain, + account extends Account, +>(client: ClientWithZksyncSsoPasskeyData): ZksyncSsoPasskeyWalletActions { + return { + deployContract: (args) => deployContract(client, args), + getAddresses: () => getAddresses(client), + getChainId: () => getChainId(client), + sendRawTransaction: (args) => sendRawTransaction(client, args), + sendTransaction: async (args) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const unformattedTx: any = Object.assign({}, args); + + if ("eip712Meta" in unformattedTx) { + const eip712Meta = unformattedTx.eip712Meta as ZksyncEip712Meta; + unformattedTx.gasPerPubdata = eip712Meta.gasPerPubdata ? BigInt(eip712Meta.gasPerPubdata) : undefined; + unformattedTx.factoryDeps = eip712Meta.factoryDeps; + unformattedTx.customSignature = eip712Meta.customSignature; + unformattedTx.paymaster = eip712Meta.paymasterParams?.paymaster; + unformattedTx.paymasterInput = eip712Meta.paymasterParams?.paymasterInput ? bytesToHex(new Uint8Array(eip712Meta.paymasterParams?.paymasterInput)) : undefined; + delete unformattedTx.eip712Meta; + } + + const formatters = client.chain?.formatters; + const format = formatters?.transaction?.format || formatTransaction; + + const tx = { + ...format(unformattedTx), + type: "eip712", + }; + + return await sendEip712Transaction(client, tx); + }, + signMessage: (args) => signMessage(client, args), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + signTransaction: (args) => signTransaction(client, args as any), + signTypedData: (args) => signTypedData(client, args), + writeContract: (args) => writeContract(client, args), + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 475f031..94760d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,6 +156,9 @@ importers: examples/demo-app: dependencies: + '@matterlabs/zksync-contracts': + specifier: ^0.6.1 + version: 0.6.1(@openzeppelin/contracts-upgradeable@4.9.6)(@openzeppelin/contracts@5.1.0) '@nuxtjs/google-fonts': specifier: ^3.2.0 version: 3.2.0(magicast@0.3.5)(rollup@4.24.0) @@ -5006,6 +5009,7 @@ packages: '@web3modal/core@5.1.11': resolution: {integrity: sha512-ugUVFVml1vVW+V7yxkn/AYYdrUJzn4ulFbDlxDMpmukKY6sDYLMMGAJ84O8ZC/OPyC7009NYd3mKZurxEyWkHw==} + deprecated: Web3Modal is now Reown AppKit. Please follow the upgrade guide at https://docs.reown.com/appkit/upgrade/from-w3m-to-reown '@web3modal/polyfills@5.1.11': resolution: {integrity: sha512-BDIDYA2LGTCquahbZ+wyWQy4IBOPeKVSgt4ZpFir1fnVJUPkEluSwZStcKLtCzQvxJgER1sLicUrjJQHF36TOg==} @@ -5018,9 +5022,11 @@ packages: '@web3modal/siwe@5.1.11': resolution: {integrity: sha512-1aKEtMosACyY0SRjHjdcA/g3bRtMojTxlK7S/T6zBk57X/P3xcEZq9J8UM73plmGewjZdLaqGMgv6B/k/WleZQ==} + deprecated: Web3Modal is now Reown AppKit. Please follow the upgrade guide at https://docs.reown.com/appkit/upgrade/from-w3m-to-reown '@web3modal/ui@5.1.11': resolution: {integrity: sha512-L0L+2YOK+ONx+W7GPtkSdKZuAQ8cjcS5N8kp+WZzKOMUTeDLuXKtSnES4p/ShOVmkpV6qB8r0pPA9xgFh1D3ow==} + deprecated: Web3Modal is now Reown AppKit. Please follow the upgrade guide at https://docs.reown.com/appkit/upgrade/from-w3m-to-reown '@web3modal/wagmi@5.1.11': resolution: {integrity: sha512-etV1qfBVvh41EMuBHXUpcO/W818jZVNh5/l9Z5kqRPZxlQmBaJbt5mTzw6nw/Lujoe1yYKugGQFhgjfEQK+eyA==} @@ -13545,7 +13551,7 @@ snapshots: - encoding - supports-color - '@matterlabs/hardhat-zksync-deploy@1.5.0(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10))(hardhat@2.22.17(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.13))(@types/node@20.16.10)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))(zksync-ethers@6.15.0(ethers@6.13.2(bufferutil@4.0.8)(utf-8-validate@5.0.10)))': + '@matterlabs/hardhat-zksync-deploy@1.5.0(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10))(hardhat@2.22.17(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.13))(@types/node@20.16.10)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))(zksync-ethers@6.15.0(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10)))': dependencies: '@matterlabs/hardhat-zksync-solc': 1.2.5(hardhat@2.22.17(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.13))(@types/node@20.16.10)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)) chai: 4.5.0 @@ -13558,7 +13564,7 @@ snapshots: sinon: 18.0.1 sinon-chai: 3.7.0(chai@4.5.0)(sinon@18.0.1) ts-morph: 22.0.0 - zksync-ethers: 6.15.0(ethers@6.13.2(bufferutil@4.0.8)(utf-8-validate@5.0.10)) + zksync-ethers: 6.15.0(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10)) transitivePeerDependencies: - encoding - supports-color @@ -13600,16 +13606,16 @@ snapshots: - typescript - utf-8-validate - '@matterlabs/hardhat-zksync-ethers@1.2.1(bufferutil@4.0.8)(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10))(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.13))(@types/node@20.16.10)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)(zksync-ethers@6.15.0(ethers@6.13.2(bufferutil@4.0.8)(utf-8-validate@5.0.10)))': + '@matterlabs/hardhat-zksync-ethers@1.2.1(bufferutil@4.0.8)(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10))(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.13))(@types/node@20.16.10)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)(zksync-ethers@6.15.0(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10)))': dependencies: - '@matterlabs/hardhat-zksync-deploy': 1.5.0(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10))(hardhat@2.22.17(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.13))(@types/node@20.16.10)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))(zksync-ethers@6.15.0(ethers@6.13.2(bufferutil@4.0.8)(utf-8-validate@5.0.10))) + '@matterlabs/hardhat-zksync-deploy': 1.5.0(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10))(hardhat@2.22.17(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.13))(@types/node@20.16.10)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))(zksync-ethers@6.15.0(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10))) '@matterlabs/hardhat-zksync-solc': 1.2.5(hardhat@2.22.17(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.13))(@types/node@20.16.10)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)) '@nomicfoundation/hardhat-ethers': 3.0.8(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10))(hardhat@2.22.17(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.13))(@types/node@20.16.10)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)) chai: 4.5.0 chalk: 4.1.2 ethers: 6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10) hardhat: 2.22.17(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.13))(@types/node@20.16.10)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10) - zksync-ethers: 6.15.0(ethers@6.13.2(bufferutil@4.0.8)(utf-8-validate@5.0.10)) + zksync-ethers: 6.15.0(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10)) transitivePeerDependencies: - bufferutil - c-kzg @@ -13730,8 +13736,8 @@ snapshots: '@matterlabs/hardhat-zksync-upgradable@1.7.0(@nomicfoundation/hardhat-ethers@3.0.8(ethers@6.13.2(bufferutil@4.0.8)(utf-8-validate@5.0.10))(hardhat@2.22.17(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.13))(@types/node@20.16.10)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)))(@nomicfoundation/hardhat-verify@2.0.11(hardhat@2.22.17(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.13))(@types/node@20.16.10)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.13))(@types/node@20.16.10)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)': dependencies: - '@matterlabs/hardhat-zksync-deploy': 1.5.0(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10))(hardhat@2.22.17(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.13))(@types/node@20.16.10)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))(zksync-ethers@6.15.0(ethers@6.13.2(bufferutil@4.0.8)(utf-8-validate@5.0.10))) - '@matterlabs/hardhat-zksync-ethers': 1.2.1(bufferutil@4.0.8)(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10))(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.13))(@types/node@20.16.10)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)(zksync-ethers@6.15.0(ethers@6.13.2(bufferutil@4.0.8)(utf-8-validate@5.0.10))) + '@matterlabs/hardhat-zksync-deploy': 1.5.0(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10))(hardhat@2.22.17(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.13))(@types/node@20.16.10)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))(zksync-ethers@6.15.0(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10))) + '@matterlabs/hardhat-zksync-ethers': 1.2.1(bufferutil@4.0.8)(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10))(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.13))(@types/node@20.16.10)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)(zksync-ethers@6.15.0(ethers@6.13.4(bufferutil@4.0.8)(utf-8-validate@5.0.10))) '@matterlabs/hardhat-zksync-solc': 1.2.5(hardhat@2.22.17(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.7.26(@swc/helpers@0.5.13))(@types/node@20.16.10)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)) '@openzeppelin/contracts-hardhat-zksync-upgradable': '@openzeppelin/contracts@5.1.0' '@openzeppelin/defender-sdk-base-client': 1.15.0 @@ -13887,6 +13893,11 @@ snapshots: '@openzeppelin/contracts': 4.9.6 '@openzeppelin/contracts-upgradeable': 4.9.6 + '@matterlabs/zksync-contracts@0.6.1(@openzeppelin/contracts-upgradeable@4.9.6)(@openzeppelin/contracts@5.1.0)': + dependencies: + '@openzeppelin/contracts': 5.1.0 + '@openzeppelin/contracts-upgradeable': 4.9.6 + '@metamask/eth-json-rpc-provider@1.0.1': dependencies: '@metamask/json-rpc-engine': 7.3.3 @@ -15046,8 +15057,8 @@ snapshots: dependencies: '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.24.0) '@rollup/plugin-replace': 5.0.7(rollup@4.24.0) - '@vitejs/plugin-vue': 5.1.4(vite@5.4.10(@types/node@22.8.0)(sass@1.80.4)(terser@5.36.0))(vue@3.5.12(typescript@5.6.2)) - '@vitejs/plugin-vue-jsx': 4.0.1(vite@5.4.10(@types/node@22.8.0)(sass@1.80.4)(terser@5.36.0))(vue@3.5.12(typescript@5.6.2)) + '@vitejs/plugin-vue': 5.1.4(vite@5.4.10(@types/node@20.16.10)(sass@1.80.4)(terser@5.36.0))(vue@3.5.12(typescript@5.6.2)) + '@vitejs/plugin-vue-jsx': 4.0.1(vite@5.4.10(@types/node@20.16.10)(sass@1.80.4)(terser@5.36.0))(vue@3.5.12(typescript@5.6.2)) autoprefixer: 10.4.20(postcss@8.4.47) clear: 0.1.0 consola: 3.2.3 @@ -15075,7 +15086,7 @@ snapshots: unplugin: 1.14.1 vite: 5.4.10(@types/node@20.16.10)(sass@1.80.4)(terser@5.36.0) vite-node: 2.1.3(@types/node@20.16.10)(sass@1.80.4)(terser@5.36.0) - vite-plugin-checker: 0.8.0(eslint@9.11.1(jiti@2.4.1))(optionator@0.9.4)(typescript@5.6.2)(vite@5.4.10(@types/node@22.8.0)(sass@1.80.4)(terser@5.36.0)) + vite-plugin-checker: 0.8.0(eslint@9.11.1(jiti@2.4.1))(optionator@0.9.4)(typescript@5.6.2)(vite@5.4.10(@types/node@20.16.10)(sass@1.80.4)(terser@5.36.0)) vue: 3.5.12(typescript@5.6.2) vue-bundle-renderer: 2.1.1 transitivePeerDependencies: @@ -17077,6 +17088,16 @@ snapshots: - encoding - supports-color + '@vitejs/plugin-vue-jsx@4.0.1(vite@5.4.10(@types/node@20.16.10)(sass@1.80.4)(terser@5.36.0))(vue@3.5.12(typescript@5.6.2))': + dependencies: + '@babel/core': 7.26.0 + '@babel/plugin-transform-typescript': 7.25.9(@babel/core@7.26.0) + '@vue/babel-plugin-jsx': 1.2.5(@babel/core@7.26.0) + vite: 5.4.10(@types/node@20.16.10)(sass@1.80.4)(terser@5.36.0) + vue: 3.5.12(typescript@5.6.2) + transitivePeerDependencies: + - supports-color + '@vitejs/plugin-vue-jsx@4.0.1(vite@5.4.10(@types/node@22.8.0)(sass@1.80.4)(terser@5.36.0))(vue@3.5.12(typescript@5.6.2))': dependencies: '@babel/core': 7.26.0 @@ -17087,6 +17108,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-vue@5.1.4(vite@5.4.10(@types/node@20.16.10)(sass@1.80.4)(terser@5.36.0))(vue@3.5.12(typescript@5.6.2))': + dependencies: + vite: 5.4.10(@types/node@20.16.10)(sass@1.80.4)(terser@5.36.0) + vue: 3.5.12(typescript@5.6.2) + '@vitejs/plugin-vue@5.1.4(vite@5.4.10(@types/node@22.8.0)(sass@1.80.4)(terser@5.36.0))(vue@3.5.12(typescript@5.6.2))': dependencies: vite: 5.4.10(@types/node@22.8.0)(sass@1.80.4)(terser@5.36.0) @@ -23096,7 +23122,7 @@ snapshots: vue: 3.5.12(typescript@5.6.2) vue-bundle-renderer: 2.1.1 vue-devtools-stub: 0.1.0 - vue-router: 4.4.5(vue@3.5.12(typescript@5.6.2)) + vue-router: 4.4.5(vue@3.5.13(typescript@5.6.2)) optionalDependencies: '@parcel/watcher': 2.4.1 '@types/node': 22.8.0 @@ -25494,7 +25520,7 @@ snapshots: unplugin: 1.14.1 yaml: 2.6.0 optionalDependencies: - vue-router: 4.4.5(vue@3.5.12(typescript@5.6.2)) + vue-router: 4.4.5(vue@3.5.13(typescript@5.6.2)) transitivePeerDependencies: - rollup - vue @@ -25673,6 +25699,28 @@ snapshots: - supports-color - terser + vite-plugin-checker@0.8.0(eslint@9.11.1(jiti@2.4.1))(optionator@0.9.4)(typescript@5.6.2)(vite@5.4.10(@types/node@20.16.10)(sass@1.80.4)(terser@5.36.0)): + dependencies: + '@babel/code-frame': 7.26.0 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + chokidar: 3.6.0 + commander: 8.3.0 + fast-glob: 3.3.2 + fs-extra: 11.2.0 + npm-run-path: 4.0.1 + strip-ansi: 6.0.1 + tiny-invariant: 1.3.3 + vite: 5.4.10(@types/node@20.16.10)(sass@1.80.4)(terser@5.36.0) + vscode-languageclient: 7.0.0 + vscode-languageserver: 7.0.0 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.0.8 + optionalDependencies: + eslint: 9.11.1(jiti@2.4.1) + optionator: 0.9.4 + typescript: 5.6.2 + vite-plugin-checker@0.8.0(eslint@9.11.1(jiti@2.4.1))(optionator@0.9.4)(typescript@5.6.2)(vite@5.4.10(@types/node@22.8.0)(sass@1.80.4)(terser@5.36.0)): dependencies: '@babel/code-frame': 7.26.0 @@ -25841,6 +25889,11 @@ snapshots: '@vue/devtools-api': 6.6.4 vue: 3.5.12(typescript@5.6.2) + vue-router@4.4.5(vue@3.5.13(typescript@5.6.2)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.13(typescript@5.6.2) + vue-router@4.5.0(vue@3.5.13(typescript@5.6.2)): dependencies: '@vue/devtools-api': 6.6.4