diff --git a/examples/demo-app/README.md b/examples/demo-app/README.md index 11959b08c..dae1f780d 100644 --- a/examples/demo-app/README.md +++ b/examples/demo-app/README.md @@ -25,3 +25,19 @@ pnpm nx dev demo-app ``` The output will list the localhost addresses for both running applications. + +## Network configuration (contracts.json) + +The demo app reads network and validator addresses from `/public/contracts.json` +at runtime. Ensure the file contains the following fields: + +- `rpcUrl`: JSON-RPC endpoint for the local node +- `bundlerUrl`: ERC-4337 bundler URL +- `entryPoint`: EntryPoint contract address +- `factory`: Account factory address +- `eoaValidator`: EOA validator address +- `webauthnValidator`: WebAuthn validator address (for passkey flows) +- `sessionValidator`: SessionKey validator address (for session flows) + +Session flows in the Web SDK (deploy-with-session and post-deploy install) +require `sessionValidator` to be set. diff --git a/examples/demo-app/components/SessionConfig.vue b/examples/demo-app/components/SessionConfig.vue new file mode 100644 index 000000000..ed3e96367 --- /dev/null +++ b/examples/demo-app/components/SessionConfig.vue @@ -0,0 +1,183 @@ + + + diff --git a/examples/demo-app/components/TransactionSender.vue b/examples/demo-app/components/TransactionSender.vue index fb9ffa15f..0a75d7d68 100644 --- a/examples/demo-app/components/TransactionSender.vue +++ b/examples/demo-app/components/TransactionSender.vue @@ -10,7 +10,7 @@
@@ -24,7 +24,10 @@ > EOA Validator (Private Key) -
+ +
+ + +

+ The private key for the session signer address +

+
+
@@ -63,7 +92,15 @@ class="w-full px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:bg-gray-400 disabled:cursor-not-allowed" @click="sendTransaction" > - {{ loading ? "Sending..." : (signingMethod === 'passkey' ? 'Send with Passkey' : 'Send with EOA') }} + {{ + loading + ? "Sending..." + : signingMethod === 'passkey' + ? 'Send with Passkey' + : signingMethod === 'session' + ? 'Send with Session Key' + : 'Send with EOA' + }}
@@ -72,7 +109,7 @@ v-if="txResult" class="mt-4 p-3 bg-white rounded border border-indigo-300" > - Transaction Hash: + Send Transaction Hash: {{ txResult }} @@ -91,7 +128,7 @@ diff --git a/examples/demo-app/pages/web-sdk-test.vue b/examples/demo-app/pages/web-sdk-test.vue index 316a6b999..410aec1de 100644 --- a/examples/demo-app/pages/web-sdk-test.vue +++ b/examples/demo-app/pages/web-sdk-test.vue @@ -54,6 +54,9 @@ + + + @@ -86,6 +89,9 @@
Passkey Enabled: Yes
+
+ Session Enabled: Yes +
@@ -169,7 +175,7 @@ v-if="fundResult" class="mt-4 p-3 bg-white rounded border border-orange-300" > - Transaction Hash: + Funding Transaction Hash: {{ fundResult }} @@ -186,11 +192,56 @@ + +
+

+ Install Session Post-Deploy +

+

+ You can install a SessionKeyValidator and create a session on an already deployed account. + This will authorize limited, gasless operations with the configured signer and limits. +

+ +
+ + +
+ Result: +

+ {{ sessionInstallResult }} +

+
+ +
+ Error: +

+ {{ sessionInstallError }} +

+
+
+
+ @@ -321,6 +372,10 @@ const fundParams = ref({ const fundResult = ref(""); const fundError = ref(""); +// Session install state (post-deploy flow) +const sessionInstallResult = ref(""); +const sessionInstallError = ref(""); + // Passkey configuration state const passkeyConfig = ref({ enabled: false, @@ -331,6 +386,21 @@ const passkeyConfig = ref({ validatorAddress: "", }); +// Session configuration state +const sessionConfig = ref({ + enabled: false, + signer: "", // Will be auto-generated by SessionConfig component + privateKey: "", // Will be auto-generated by SessionConfig component + expiresInDays: 1, + feeLimitEth: "0.1", + transfers: [ + { + to: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", // Example target + valueLimitEth: "0.1", + }, + ], +}); + // Wallet configuration state const walletConfig = ref({ source: "anvil", // 'anvil' | 'private-key' | 'browser-wallet' @@ -474,7 +544,7 @@ async function deployAccount() { try { // Import the web SDK - destructure the WASM functions we need - const { deploy_account, compute_account_id, DeployAccountConfig } = await import("zksync-sso-web-sdk/bundler"); + const { compute_account_id, DeployAccountConfig, deployAccountWithSession } = await import("zksync-sso-web-sdk/bundler"); // Generate a user ID (in real app, this would be from authentication) const userId = "demo-user-" + Date.now(); @@ -488,6 +558,7 @@ async function deployAccount() { let factoryAddress = "0x679FFF51F11C3f6CaC9F2243f9D14Cb1255F65A3"; // Default fallback let rpcUrl = "http://localhost:8545"; // Default to Anvil let eoaValidatorAddress = null; + let sessionValidatorAddress = null; try { // Try to load contracts.json if it exists @@ -502,7 +573,10 @@ async function deployAccount() { // eslint-disable-next-line no-console console.log("Loaded EOA validator address from contracts.json:", eoaValidatorAddress); // eslint-disable-next-line no-console - console.log("Loaded WebAuthn validator address from contracts.json:", webAuthnValidatorAddress); + console.log("Loaded WebAuthn validator address from contracts.json:", contracts.webauthnValidator); + // eslint-disable-next-line no-console + console.log("Loaded Session validator address from contracts.json:", contracts.sessionValidator); + sessionValidatorAddress = contracts.sessionValidator || null; } else { // eslint-disable-next-line no-console console.warn("contracts.json not found, using default factory address"); @@ -591,21 +665,47 @@ async function deployAccount() { } // Construct the DeployAccountConfig wasm object + // Include session validator in deploy config if present (optional arg) const deployConfig = new DeployAccountConfig( rpcUrl, factoryAddress, deployerPrivateKey, eoaValidatorAddress, webauthnValidatorAddress, // webauthn validator (null if not using passkeys) + sessionValidatorAddress, // session validator (null if not using sessions) ); + // Optionally build session payload if enabled + let session = null; + if (sessionConfig.value.enabled) { + const { ethers } = await import("ethers"); + const nowSec = Math.floor(Date.now() / 1000); + const expiresAt = nowSec + sessionConfig.value.expiresInDays * 86400; + session = { + signer: sessionConfig.value.signer, + expiresAt, + feeLimit: { + limitType: "lifetime", + limit: ethers.parseEther(sessionConfig.value.feeLimitEth), + }, + transfers: [ + { + to: sessionConfig.value.transfers[0].to, + valueLimit: ethers.parseEther(sessionConfig.value.transfers[0].valueLimitEth), + }, + ], + }; + } + // Call the deployment function with the structured config - const deployedAddress = await deploy_account( + // Use session-aware deploy wrapper (passes through to WASM) + const deployedAddress = await deployAccountWithSession({ userId, - eoaSignersAddresses, - passkeyPayload, // passkey payload (null if not using passkeys) + eoaSigners: eoaSignersAddresses, + passkeyPayload, // null if not using passkeys + session, // null if not using sessions deployConfig, - ); + }); // eslint-disable-next-line no-console console.log("Account deployed at:", deployedAddress); @@ -617,6 +717,7 @@ async function deployAccount() { address: deployedAddress, eoaSigner: eoaSignerAddress, passkeyEnabled: passkeyConfig.value.enabled, + sessionEnabled: sessionConfig.value.enabled, }; // If passkey was provided during deployment, it's automatically registered @@ -624,9 +725,16 @@ async function deployAccount() { passkeyRegistered.value = true; } - testResult.value = passkeyConfig.value.enabled - ? "Account deployed successfully with EOA signer and WebAuthn passkey! (Passkey automatically registered)" - : "Account deployed successfully with EOA signer!"; + testResult.value = "Account deployed successfully"; + if (passkeyConfig.value.enabled) { + testResult.value += " with WebAuthn passkey!"; + } + if (sessionConfig.value.enabled) { + testResult.value += " with session validator!"; + } + if (!sessionConfig.value.enabled && !passkeyConfig.value.enabled) { + testResult.value += " with EOA signer!"; + } // eslint-disable-next-line no-console console.log("Account deployment result:", deploymentResult.value); @@ -639,6 +747,90 @@ async function deployAccount() { } } +// Install Session on an already deployed account (post-deploy flow) +async function installSessionPostDeploy() { + loading.value = true; + sessionInstallError.value = ""; + sessionInstallResult.value = ""; + + try { + if (!deploymentResult.value || !deploymentResult.value.address) { + throw new Error("Smart account not deployed yet"); + } + + // Import the wrapper from the web SDK + const { addSessionToAccount } = await import("zksync-sso-web-sdk/bundler"); + + // Load network and validator details + const response = await fetch("/contracts.json"); + if (!response.ok) { + throw new Error("contracts.json not found - cannot load network configuration"); + } + const contracts = await response.json(); + const rpcUrl = contracts.rpcUrl; + const bundlerUrl = contracts.bundlerUrl || "http://localhost:4337"; + const entryPointAddress = contracts.entryPoint || "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108"; + const eoaValidatorAddress = contracts.eoaValidator; + const sessionValidatorAddress = contracts.sessionValidator; + + if (!sessionValidatorAddress) { + throw new Error("Session validator address not found in contracts.json"); + } + + // Use Anvil account #1 as the EOA authorizer for installing the session + const eoaSignerPrivateKey = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; + + // Build session config from UI values + const { ethers } = await import("ethers"); + const nowSec = Math.floor(Date.now() / 1000); + const expiresAt = nowSec + sessionConfig.value.expiresInDays * 86400; + const session = { + signer: sessionConfig.value.signer, + expiresAt, + feeLimit: { + limitType: "lifetime", + limit: ethers.parseEther(sessionConfig.value.feeLimitEth), + }, + transfers: [ + { + to: sessionConfig.value.transfers[0].to, + valueLimit: ethers.parseEther(sessionConfig.value.transfers[0].valueLimitEth), + }, + ], + }; + + // Build SendTransactionConfig (wasm class instance) + const { SendTransactionConfig } = await import("zksync-sso-web-sdk/bundler"); + const txConfig = new SendTransactionConfig( + rpcUrl, + bundlerUrl, + entryPointAddress, + ); + + // Execute the install + const res = await addSessionToAccount({ + txConfig, + accountAddress: deploymentResult.value.address, + session, + sessionValidatorAddress, + eoaValidatorAddress, + eoaPrivateKey: eoaSignerPrivateKey, + }); + + if (typeof res === "string" && (res.startsWith("Failed") || res.startsWith("Error"))) { + throw new Error(res); + } + + sessionInstallResult.value = typeof res === "string" ? res : "Session installed successfully!"; + } catch (err) { + // eslint-disable-next-line no-console + console.error("Session install failed:", err); + sessionInstallError.value = `Failed to install session: ${err.message}`; + } finally { + loading.value = false; + } +} + // Register the passkey with the WebAuthn validator async function registerPasskey() { loading.value = true; diff --git a/examples/demo-app/tests/web-sdk-test.spec.ts b/examples/demo-app/tests/web-sdk-test.spec.ts index ee418b96b..b28de4e6c 100644 --- a/examples/demo-app/tests/web-sdk-test.spec.ts +++ b/examples/demo-app/tests/web-sdk-test.spec.ts @@ -45,7 +45,7 @@ test("Deploy, fund, and transfer from smart account", async ({ page }) => { await page.getByRole("button", { name: "Deploy Account" }).click(); // Wait for deployment to complete - look for the success message - await expect(page.getByText("Account Deployed Successfully!")).toBeVisible({ timeout: 30000 }); + await expect(page.getByText("Account deployed successfully with EOA signer!")).toBeVisible({ timeout: 30000 }); // Verify deployment result has required information await expect(page.getByText("Account Address:")).toBeVisible(); @@ -65,7 +65,7 @@ test("Deploy, fund, and transfer from smart account", async ({ page }) => { await page.getByRole("button", { name: "Fund Smart Account" }).click(); // Wait for funding transaction to complete - look for transaction hash - await expect(page.getByText("Transaction Hash:")).toBeVisible({ timeout: 30000 }); + await expect(page.getByText("Funding Transaction Hash:")).toBeVisible({ timeout: 30000 }); // Verify we have a transaction hash displayed const fundTxHash = page.locator("code").filter({ hasText: /^0x[a-fA-F0-9]{64}/ }).first(); @@ -96,7 +96,7 @@ test("Deploy, fund, and transfer from smart account", async ({ page }) => { // Wait for transaction to complete - look for transaction hash in the send section // We need to find the second occurrence of "Transaction Hash:" since fund also has one - await expect(page.locator("strong:has-text(\"Transaction Hash:\")").nth(1)).toBeVisible({ timeout: 30000 }); + await expect(page.locator("strong:has-text(\"Transaction Hash:\")")).toBeVisible({ timeout: 30000 }); await expect(page.getByText("Transaction failed: Failed to submit UserOperation:")).not.toBeVisible(); // Verify we have a transaction hash for the send @@ -169,7 +169,7 @@ test("Deploy with passkey and send transaction using passkey", async ({ page }) await page.getByRole("button", { name: "Fund Smart Account" }).click(); // Wait for funding transaction to complete - await expect(page.getByText("Transaction Hash:"), "Funding failed").toBeVisible({ timeout: 30000 }); + await expect(page.getByText("Funding Transaction Hash:"), "Funding failed").toBeVisible({ timeout: 30000 }); console.log("✓ Smart account funded successfully"); @@ -197,7 +197,7 @@ test("Deploy with passkey and send transaction using passkey", async ({ page }) await page.getByRole("button", { name: "Send with Passkey" }).click(); // Wait for transaction to complete - await expect(page.getByText("Transaction confirmed! UserOp hash:")).toBeVisible({ timeout: 60000 }); + await expect(page.getByText("Transaction confirmed! UserOp hash:")).toBeVisible({ timeout: 30000 }); await expect(page.getByText("Transaction failed: Failed to submit UserOperation:")).not.toBeVisible(); // Verify we have a transaction hash for the send @@ -208,3 +208,104 @@ test("Deploy with passkey and send transaction using passkey", async ({ page }) console.log("✅ All passkey steps completed successfully!"); }); + +test("Deploy with session at deploy", async ({ page }) => { + // Use Anvil account #4 to avoid nonce conflicts + await page.goto("/web-sdk-test?fundingAccount=4"); + await expect(page.getByText("ZKSync SSO Web SDK Test")).toBeVisible(); + + // Wait for SDK to load + await expect(page.getByText("SDK Loaded:")).toBeVisible(); + await expect(page.getByText("Yes")).toBeVisible({ timeout: 10000 }); + + // Enable session at deploy + const sessionToggle = page.getByText("Enable session at deploy").locator("..").locator("input[type=checkbox]"); + await expect(sessionToggle).toBeVisible(); + await sessionToggle.check(); + + // Deploy account + await page.getByRole("button", { name: "Deploy Account" }).click(); + + // Wait for deployment success and verify session enabled + await expect(page.getByText("Account Deployed Successfully!")).toBeVisible({ timeout: 30000 }); + await expect(page.getByText("Session Enabled: Yes")).toBeVisible(); +}); + +test("Install session post-deploy", async ({ page }) => { + // Use Anvil account #5 to avoid nonce conflicts + await page.goto("/web-sdk-test?fundingAccount=5"); + await expect(page.getByText("ZKSync SSO Web SDK Test")).toBeVisible(); + + // Wait for SDK to load + await expect(page.getByText("SDK Loaded:")).toBeVisible(); + await expect(page.getByText("Yes")).toBeVisible({ timeout: 10000 }); + + // Ensure session-at-deploy is not enabled (default is unchecked) + // Deploy account + await page.getByRole("button", { name: "Deploy Account" }).click(); + await expect(page.getByText("Account deployed successfully with EOA signer!")).toBeVisible({ timeout: 30000 }); + + // Session should not be enabled yet + await expect(page.getByText("Session Enabled: Yes")).not.toBeVisible(); + + // Install session post-deploy + await page.getByRole("button", { name: "Install Session Module" }).click(); + + // Expect result to show up + await expect(page.getByText("Result:")).toBeVisible({ timeout: 30000 }); +}); + +test("Send transaction with session key", async ({ page }) => { + // Use Anvil account #6 to avoid nonce conflicts + await page.goto("/web-sdk-test?fundingAccount=6"); + await expect(page.getByText("ZKSync SSO Web SDK Test")).toBeVisible(); + + // Wait for SDK to load + await expect(page.getByText("SDK Loaded:")).toBeVisible(); + await expect(page.getByText("Yes")).toBeVisible({ timeout: 10000 }); + + console.log("Step 1: Deploy account with session (using deploy-with-session flow)..."); + + // Enable session at deploy (this works from other tests) + const sessionToggle = page.getByText("Enable session at deploy").locator("..").locator("input[type=checkbox]"); + await expect(sessionToggle).toBeVisible(); + await sessionToggle.check(); + + // Deploy account with session + await page.getByRole("button", { name: "Deploy Account" }).click(); + await expect(page.getByText("Account Deployed Successfully!")).toBeVisible({ timeout: 30000 }); + await expect(page.getByText("Session Enabled: Yes")).toBeVisible(); + + console.log("Step 2: Fund the smart account..."); + + // Fund the account + await page.getByRole("button", { name: "Fund Smart Account" }).click(); + await expect(page.getByText("Funding Transaction Hash:")).toBeVisible({ timeout: 30000 }); + + console.log("Step 3: Send transaction with session key..."); + + // Select session key signing method + const sessionRadio = page.getByText("Session Key (Gasless within limits)").locator("..").locator("input[type=radio]"); + await expect(sessionRadio).toBeVisible(); + await sessionRadio.check(); + + /* + // Verify session private key is pre-filled (it's auto-generated now) + const sessionKeyInput = page.getByLabel("Session Private Key:"); + await expect(sessionKeyInput).toBeVisible(); + + // Check that the input has a value (the auto-generated private key) + const privateKeyValue = await sessionKeyInput.inputValue(); + expect(privateKeyValue).toMatch(/^0x[a-fA-F0-9]{64}$/); // Valid private key format + + console.log(" Session private key auto-filled:", privateKeyValue.substring(0, 10) + "..."); + */ + + // Send transaction + await page.getByRole("button", { name: "Send with Session Key" }).click(); + + // Wait for transaction result + await expect(page.getByText("Send Transaction Hash:")).toBeVisible({ timeout: 30000 }); + + console.log("✓ Session transaction sent successfully!"); +}); diff --git a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/erc7579/add_module.rs b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/erc7579/add_module.rs index ca50c907b..34882001d 100644 --- a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/erc7579/add_module.rs +++ b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/erc7579/add_module.rs @@ -124,6 +124,7 @@ mod tests { factory_address, eoa_signers: Some(eoa_signers), webauthn_signer: None, + session_signer: None, id: None, provider: provider.clone(), }) diff --git a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/add_passkey.rs b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/add_passkey.rs index 1e1e32750..a0203a722 100644 --- a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/add_passkey.rs +++ b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/add_passkey.rs @@ -148,6 +148,7 @@ mod tests { factory_address, eoa_signers: Some(eoa_signers), webauthn_signer: None, + session_signer: None, id: None, provider: provider.clone(), }) diff --git a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/deploy.rs b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/deploy.rs index 3337b5ba8..6cac0c9c8 100644 --- a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/deploy.rs +++ b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/deploy.rs @@ -5,6 +5,7 @@ use crate::erc4337::{ MSAFactory::{self, deployAccountCall}, ModularSmartAccount::initializeAccountCall, add_passkey::PasskeyPayload, + session::session_lib::session_spec::SessionSpec, }, utils::check_deployed::{Contract, check_contract_deployed}, }; @@ -63,11 +64,18 @@ pub struct WebAuthNSigner { pub validator_address: Address, } +#[derive(Clone)] +pub struct SessionSigner { + pub session_spec: SessionSpec, + pub validator_address: Address, +} + #[derive(Clone)] pub struct DeployAccountParams { pub factory_address: Address, pub eoa_signers: Option, pub webauthn_signer: Option, + pub session_signer: Option, pub id: Option>, pub provider: P, } @@ -82,6 +90,7 @@ where factory_address, eoa_signers, webauthn_signer, + session_signer, id, provider, } = params; @@ -189,6 +198,7 @@ mod tests { factory_address, eoa_signers: None, webauthn_signer: None, + session_signer: None, id: None, provider: provider.clone(), }) @@ -219,6 +229,7 @@ mod tests { factory_address, eoa_signers: Some(eoa_signers), webauthn_signer: None, + session_signer: None, id: None, provider: provider.clone(), }) @@ -238,4 +249,92 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn test_deploy_account_with_session() -> eyre::Result<()> { + use crate::erc4337::account::modular_smart_account::session::session_lib::session_spec::{ + SessionSpec, transfer_spec::TransferSpec, usage_limit::UsageLimit, limit_type::LimitType, + }; + use alloy::primitives::{U256, aliases::U48}; + + let (_, anvil_instance, provider, contracts, _) = + start_anvil_and_deploy_contracts().await?; + + let factory_address = contracts.account_factory; + let eoa_validator_address = contracts.eoa_validator; + let session_validator_address = contracts.session_validator; + + // Create EOA signers for deployment authorization + let signers = + vec![address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266")]; + let eoa_signers = EOASigners { + addresses: signers, + validator_address: eoa_validator_address, + }; + + // Create a session spec + let session_signer_address = + address!("0x70997970C51812dc3A010C7d01b50e0d17dc79C8"); + let transfer_target = + address!("0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"); + + let session_spec = SessionSpec { + signer: session_signer_address, + expires_at: U48::from((1u64 << 48) - 1), // Max U48 value + fee_limit: UsageLimit { + limit_type: LimitType::Unlimited, + limit: U256::ZERO, + period: U48::ZERO, + }, + call_policies: vec![], + transfer_policies: vec![TransferSpec { + target: transfer_target, + max_value_per_use: U256::from(1_000_000_000_000_000_000u64), // 1 ETH + value_limit: UsageLimit { + limit_type: LimitType::Lifetime, + limit: U256::from(10_000_000_000_000_000_000u64), // 10 ETH lifetime + period: U48::ZERO, + }, + }], + }; + + let session_signer = SessionSigner { + session_spec, + validator_address: session_validator_address, + }; + + let address = deploy_account(DeployAccountParams { + factory_address, + eoa_signers: Some(eoa_signers), + webauthn_signer: None, + session_signer: Some(session_signer), + id: None, + provider: provider.clone(), + }) + .await?; + + println!("Account deployed with session: {:?}", address); + + // Verify EOA module is installed + let is_eoa_installed = is_module_installed( + eoa_validator_address, + address, + provider.clone(), + ) + .await?; + eyre::ensure!(is_eoa_installed, "EOA module is not installed"); + + // Verify session module is installed + let is_session_installed = is_module_installed( + session_validator_address, + address, + provider.clone(), + ) + .await?; + eyre::ensure!(is_session_installed, "Session module is not installed"); + + drop(anvil_instance); + + Ok(()) + } } diff --git a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/deploy/user_op.rs b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/deploy/user_op.rs index ff4a20d39..14330c23b 100644 --- a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/deploy/user_op.rs +++ b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/deploy/user_op.rs @@ -48,6 +48,7 @@ where factory_address, eoa_signers, webauthn_signer, + session_signer: _, id, provider, } = deploy_params; @@ -185,6 +186,7 @@ mod tests { factory_address, eoa_signers: Some(eoa_signers), webauthn_signer: None, + session_signer: None, id: None, provider: provider.clone(), }, diff --git a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/send/eoa.rs b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/send/eoa.rs index b036f1994..530b931c9 100644 --- a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/send/eoa.rs +++ b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/send/eoa.rs @@ -126,6 +126,7 @@ mod tests { factory_address, eoa_signers: Some(eoa_signers), webauthn_signer: None, + session_signer: None, id: None, provider: provider.clone(), }) diff --git a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/send/passkey.rs b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/send/passkey.rs index c2b388b43..d801dc99e 100644 --- a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/send/passkey.rs +++ b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/send/passkey.rs @@ -129,6 +129,7 @@ pub mod tests { factory_address, eoa_signers: Some(eoa_signers), webauthn_signer: None, + session_signer: None, id: None, provider: provider.clone(), }) @@ -293,6 +294,7 @@ pub mod tests { factory_address, eoa_signers: Some(eoa_signers), webauthn_signer: None, + session_signer: None, id: None, provider: provider.clone(), }) diff --git a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/session/create.rs b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/session/create.rs index b6b943e7e..cfe61bf4b 100644 --- a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/session/create.rs +++ b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/session/create.rs @@ -138,6 +138,7 @@ mod tests { factory_address, eoa_signers: Some(eoa_signers), webauthn_signer: None, + session_signer: None, id: None, provider: provider.clone(), }) @@ -288,6 +289,7 @@ mod tests { factory_address, eoa_signers: None, webauthn_signer: Some(passkey_signer), + session_signer: None, id: None, provider: provider.clone(), }) diff --git a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/session/revoke.rs b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/session/revoke.rs index 994ad9a64..546ae6cad 100644 --- a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/session/revoke.rs +++ b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/session/revoke.rs @@ -142,6 +142,7 @@ mod tests { factory_address, eoa_signers: Some(eoa_signers), webauthn_signer: None, + session_signer: None, id: None, provider: provider.clone(), }) @@ -325,6 +326,7 @@ mod tests { factory_address, eoa_signers: None, webauthn_signer: Some(passkey_signer), + session_signer: None, id: None, provider: provider.clone(), }) diff --git a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/session/send.rs b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/session/send.rs index d2ba1cd71..4ed43f860 100644 --- a/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/session/send.rs +++ b/packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/session/send.rs @@ -110,6 +110,7 @@ mod tests { factory_address, eoa_signers: Some(eoa_signers), webauthn_signer: None, + session_signer: None, id: None, provider: provider.clone(), }) @@ -273,4 +274,209 @@ mod tests { Ok(()) } + + /// Negative test: attempt to send a session transaction signed by the wrong + /// session key (not the one that the session was created with). We expect + /// the bundler / validation pipeline to reject the UserOperation with an + /// AA23-style revert (invalid signature). The exact revert reason string + /// bubbles up through our error formatting, so we assert it contains the + /// substring `AA23` to guard against silent acceptance of invalid session + /// signatures. + #[tokio::test] + async fn test_send_transaction_session_wrong_key_rejected() + -> eyre::Result<()> { + let ( + _, + anvil_instance, + provider, + contracts, + signer_private_key, + bundler, + bundler_client, + ) = { + let signer_private_key = "0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6".to_string(); + let config = TestInfraConfig { + signer_private_key: signer_private_key.clone(), + }; + start_anvil_and_deploy_contracts_and_start_bundler_with_config( + &config, + ) + .await? + }; + + let session_key_module = contracts.session_validator; + let factory_address = contracts.account_factory; + let eoa_validator_address = contracts.eoa_validator; + let entry_point_address = + address!("0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108"); + + // Correct session key used when creating the session + let session_key_hex_valid = "0xb1da23908ba44fb1c6147ac1b32a1dbc6e7704ba94ec495e588d1e3cdc7ca6f9"; + let session_signer_address_valid = + PrivateKeySigner::from_str(session_key_hex_valid)?.address(); + + // Wrong session key we will use when attempting to send + let session_key_hex_wrong = "0x59c6995e998f97a5a0044966f0945382f6248550bfb224aa53a6b7d22d4d3e7a"; // different well-known test key + let session_signer_address_wrong = + PrivateKeySigner::from_str(session_key_hex_wrong)?.address(); + + // Deploy account with EOA signer + let signers = + vec![address!("0xa0Ee7A142d267C1f36714E4a8F75612F20a79720")]; + let eoa_signers = EOASigners { + addresses: signers, + validator_address: eoa_validator_address, + }; + let account_address = deploy_account(DeployAccountParams { + factory_address, + eoa_signers: Some(eoa_signers), + webauthn_signer: None, + session_signer: None, + id: None, + provider: provider.clone(), + }) + .await?; + + // Ensure EOA module is installed + eyre::ensure!( + is_module_installed( + eoa_validator_address, + account_address, + provider.clone() + ) + .await?, + "EOA validator module not installed" + ); + + fund_account_with_default_amount(account_address, provider.clone()) + .await?; + + // Add session key validator module + { + let stub_sig = stub_signature_eoa(eoa_validator_address)?; + let signer_private_key = signer_private_key.clone(); + let signature_provider = Arc::new(move |hash: FixedBytes<32>| { + eoa_signature(&signer_private_key, eoa_validator_address, hash) + }); + let signer = Signer { + provider: signature_provider, + stub_signature: stub_sig, + }; + add_module( + account_address, + session_key_module, + entry_point_address, + provider.clone(), + bundler_client.clone(), + signer, + ) + .await?; + eyre::ensure!( + is_module_installed( + session_key_module, + account_address, + provider.clone() + ) + .await?, + "Session key module not installed" + ); + } + + // Signer for creating session (EOA based) + let eoa_signer_for_session = { + let stub_sig = stub_signature_eoa(eoa_validator_address)?; + let signer_private_key = signer_private_key.clone(); + let signature_provider = Arc::new(move |hash: FixedBytes<32>| { + eoa_signature(&signer_private_key, eoa_validator_address, hash) + }); + Signer { provider: signature_provider, stub_signature: stub_sig } + }; + + // Create session with the VALID key + let session_spec = SessionSpec { + signer: session_signer_address_valid, + expires_at: Uint::from(2088558400u64), + call_policies: vec![], + fee_limit: UsageLimit { + limit_type: LimitType::Lifetime, + limit: U256::from(1_000_000_000_000_000_000u64), + period: Uint::from(0), + }, + transfer_policies: vec![TransferSpec { + max_value_per_use: U256::from(1), + target: address!("0xa0Ee7A142d267C1f36714E4a8F75612F20a79720"), + value_limit: UsageLimit { + limit_type: LimitType::Unlimited, + limit: U256::from(0), + period: Uint::from(0), + }, + }], + }; + create_session( + account_address, + session_spec.clone(), + entry_point_address, + session_key_module, + bundler_client.clone(), + provider.clone(), + eoa_signer_for_session, + ) + .await?; + + // Prepare call (value within allowed limits so only signature mismatch should matter) + let call = Execution { + target: address!("0xa0Ee7A142d267C1f36714E4a8F75612F20a79720"), + value: U256::from(1), + data: Bytes::default(), + }; + let calldata = encode_calls(vec![call]).into(); + + // WRONG session signer (not the one registered in session_spec) + let wrong_session_signer = { + let stub_sig = session_signature( + session_key_hex_wrong, + session_key_module, + &session_spec, + Default::default(), + )?; + let signature_provider = Arc::new(move |hash: FixedBytes<32>| { + session_signature( + session_key_hex_wrong, + session_key_module, + &session_spec, + hash, + ) + }); + Signer { provider: signature_provider, stub_signature: stub_sig } + }; + + let keyed_nonce_wrong = keyed_nonce(session_signer_address_wrong); + + let result = send_transaction(SendParams { + account: account_address, + entry_point: entry_point_address, + call_data: calldata, + nonce_key: Some(keyed_nonce_wrong), + paymaster: None, + bundler_client, + provider, + signer: wrong_session_signer, + }) + .await; + + eyre::ensure!( + result.is_err(), + "Expected send_transaction to fail with wrong session key, but it succeeded" + ); + + let err = format!("{result:?}"); + eyre::ensure!( + err.contains("AA23"), + "Expected error to contain AA23 (invalid signature), got: {err}" + ); + + drop(anvil_instance); + drop(bundler); + Ok(()) + } } 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 a1c1e6d2c..d0e0f310d 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 @@ -1,11 +1,12 @@ use alloy::{ network::EthereumWallet, - primitives::{Address, Bytes, FixedBytes, U256, keccak256}, + primitives::{Address, Bytes, FixedBytes, U256, aliases::U48, keccak256}, providers::ProviderBuilder, rpc::types::erc4337::PackedUserOperation as AlloyPackedUserOperation, signers::local::PrivateKeySigner, }; use alloy_rpc_client::RpcClient; +use std::str::FromStr; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::future_to_promise; use zksync_sso_erc4337_core::{ @@ -16,9 +17,16 @@ use zksync_sso_erc4337_core::{ add_passkey::PasskeyPayload as CorePasskeyPayload, deploy::{ EOASigners as CoreEOASigners, + SessionSigner as CoreSessionSigner, WebAuthNSigner as CoreWebauthNSigner, }, send::eoa::EOASendParams, + session::session_lib::session_spec::{ + SessionSpec as CoreSessionSpec, + limit_type::LimitType as CoreLimitType, + transfer_spec::TransferSpec as CoreTransferSpec, + usage_limit::UsageLimit as CoreUsageLimit, + }, signers::passkey::stub_signature_passkey, }, entry_point::version::EntryPointVersion, @@ -31,6 +39,10 @@ use zksync_sso_erc4337_core::{ mod wasm_transport; use wasm_transport::WasmHttpTransport; +// Stub private key for creating stub signatures during gas estimation +const STUB_PRIVATE_KEY: &str = + "0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6"; + // Initialize logging and panic hook for WASM #[wasm_bindgen(start)] pub fn init() { @@ -154,6 +166,112 @@ impl PasskeyPayload { } } +// Session payload for WASM +#[wasm_bindgen] +#[derive(Debug, Clone)] +pub struct TransferPayload { + target: String, + value_limit_value: String, + value_limit_type: u8, + value_limit_period: String, +} + +#[wasm_bindgen] +impl TransferPayload { + #[wasm_bindgen(constructor)] + pub fn new( + target: String, + value_limit_value: String, + value_limit_type: u8, + value_limit_period: String, + ) -> Self { + Self { target, value_limit_value, value_limit_type, value_limit_period } + } + + #[wasm_bindgen(getter)] + pub fn target(&self) -> String { + self.target.clone() + } + + #[wasm_bindgen(getter)] + pub fn value_limit_value(&self) -> String { + self.value_limit_value.clone() + } + + #[wasm_bindgen(getter)] + pub fn value_limit_type(&self) -> u8 { + self.value_limit_type + } + + #[wasm_bindgen(getter)] + pub fn value_limit_period(&self) -> String { + self.value_limit_period.clone() + } +} + +#[wasm_bindgen] +#[derive(Debug, Clone)] +pub struct SessionPayload { + signer: String, + expires_at: String, + fee_limit_value: String, + fee_limit_type: u8, + fee_limit_period: String, + transfers: Vec, +} + +#[wasm_bindgen] +impl SessionPayload { + #[wasm_bindgen(constructor)] + pub fn new( + signer: String, + expires_at: String, + fee_limit_value: String, + fee_limit_type: u8, + fee_limit_period: String, + transfers: Vec, + ) -> Self { + Self { + signer, + expires_at, + fee_limit_value, + fee_limit_type, + fee_limit_period, + transfers, + } + } + + #[wasm_bindgen(getter)] + pub fn signer(&self) -> String { + self.signer.clone() + } + + #[wasm_bindgen(getter)] + pub fn expires_at(&self) -> String { + self.expires_at.clone() + } + + #[wasm_bindgen(getter)] + pub fn fee_limit_value(&self) -> String { + self.fee_limit_value.clone() + } + + #[wasm_bindgen(getter)] + pub fn fee_limit_type(&self) -> u8 { + self.fee_limit_type + } + + #[wasm_bindgen(getter)] + pub fn fee_limit_period(&self) -> String { + self.fee_limit_period.clone() + } + + #[wasm_bindgen(getter)] + pub fn transfers(&self) -> Vec { + self.transfers.clone() + } +} + // Config for WASM account deployments #[wasm_bindgen] #[derive(Debug, Clone)] @@ -163,6 +281,7 @@ pub struct DeployAccountConfig { deployer_private_key: String, eoa_validator_address: Option, webauthn_validator_address: Option, + session_validator_address: Option, } #[wasm_bindgen] @@ -174,6 +293,7 @@ impl DeployAccountConfig { deployer_private_key: String, eoa_validator_address: Option, webauthn_validator_address: Option, + session_validator_address: Option, ) -> Self { Self { rpc_url, @@ -181,6 +301,7 @@ impl DeployAccountConfig { deployer_private_key, eoa_validator_address, webauthn_validator_address, + session_validator_address, } } @@ -208,6 +329,11 @@ impl DeployAccountConfig { pub fn webauthn_validator_address(&self) -> Option { self.webauthn_validator_address.clone() } + + #[wasm_bindgen(getter)] + pub fn session_validator_address(&self) -> Option { + self.session_validator_address.clone() + } } // Config for sending transactions from smart accounts @@ -330,14 +456,11 @@ impl PreparedUserOperation { /// Deploy a smart account using the factory /// /// # Arguments -/// * `rpc_url` - The RPC endpoint URL -/// * `factory_address` - The address of the MSA factory contract /// * `user_id` - A unique identifier for the user (will be hashed to create account_id) -/// * `deployer_private_key` - Private key of the account that will pay for deployment (0x-prefixed hex string) /// * `eoa_signers_addresses` - Optional array of EOA signer addresses (as hex strings) -/// * `eoa_validator_address` - Optional EOA validator module address (required if eoa_signers_addresses is provided) /// * `passkey_payload` - Optional WebAuthn passkey payload -/// * `webauthn_validator_address` - Optional WebAuthn validator module address (required if passkey_payload is provided) +/// * `session_payload` - Optional session configuration +/// * `deploy_account_config` - Deployment configuration including RPC URL, factory address, and validator addresses /// /// # Returns /// Promise that resolves to the deployed account address as a hex string @@ -346,6 +469,7 @@ pub fn deploy_account( user_id: String, eoa_signers_addresses: Option>, passkey_payload: Option, + session_payload: Option, deploy_account_config: DeployAccountConfig, ) -> js_sys::Promise { future_to_promise(async move { @@ -496,6 +620,170 @@ pub fn deploy_account( } }; + // Convert SessionPayload to SessionSigner if provided + let session_signer = match ( + session_payload, + deploy_account_config.session_validator_address.as_ref(), + ) { + (Some(session), Some(session_validator_str)) => { + console_log!(" Converting session payload..."); + + // Parse session validator address + let session_validator_addr = + match session_validator_str.parse::
() { + Ok(addr) => addr, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid session validator address: {}", + e + ))); + } + }; + + // Parse session signer address + let signer = match session.signer.parse::
() { + Ok(addr) => addr, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid session signer address: {}", + e + ))); + } + }; + + // Parse expires_at (U48 from string) + let expires_at = + match u64::from_str_radix(&session.expires_at, 10) { + Ok(val) => U48::from(val), + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid expires_at value: {}", + e + ))); + } + }; + + // Convert fee limit + let fee_limit_type = CoreLimitType::try_from( + session.fee_limit_type, + ) + .map_err(|e| { + JsValue::from_str(&format!("Invalid fee limit type: {}", e)) + })?; + + let fee_limit = + CoreUsageLimit { + limit_type: fee_limit_type, + limit: U256::from_str(&session.fee_limit_value) + .map_err(|e| { + JsValue::from_str(&format!( + "Invalid fee limit value: {}", + e + )) + })?, + period: U48::from_str(&session.fee_limit_period) + .map_err(|e| { + JsValue::from_str(&format!( + "Invalid fee limit period: {}", + e + )) + })?, + }; + + // Convert transfer policies + let transfer_policies: Result, JsValue> = + session + .transfers + .iter() + .map(|transfer| { + let target = transfer + .target + .parse::
() + .map_err(|e| { + JsValue::from_str(&format!( + "Invalid transfer target address: {}", + e + )) + })?; + + let max_value_per_use = + U256::from_str(&transfer.value_limit_value) + .map_err(|e| { + JsValue::from_str(&format!( + "Invalid max_value_per_use: {}", + e + )) + })?; + + let value_limit_type = CoreLimitType::try_from( + transfer.value_limit_type, + ) + .map_err(|e| { + JsValue::from_str(&format!( + "Invalid value limit type: {}", + e + )) + })?; + + let value_limit = CoreUsageLimit { + limit_type: value_limit_type, + limit: U256::from_str( + &transfer.value_limit_value, + ) + .map_err(|e| { + JsValue::from_str(&format!( + "Invalid value limit: {}", + e + )) + })?, + period: U48::from_str( + &transfer.value_limit_period, + ) + .map_err(|e| { + JsValue::from_str(&format!( + "Invalid value limit period: {}", + e + )) + })?, + }; + + Ok(CoreTransferSpec { + target, + max_value_per_use, + value_limit, + }) + }) + .collect(); + + let transfer_policies = transfer_policies?; + + // Create SessionSpec + let session_spec = CoreSessionSpec { + signer, + expires_at, + fee_limit, + call_policies: vec![], // No call policies for now + transfer_policies, + }; + + console_log!( + " Session spec created with {} transfers", + session.transfers.len() + ); + + Some(CoreSessionSigner { + session_spec, + validator_address: session_validator_addr, + }) + } + (Some(_), None) => { + return Err(JsValue::from_str( + "Session payload provided but session_validator_address is missing", + )); + } + (None, _) => None, + }; + console_log!(" Calling core deploy_account..."); // Use the core crate's deploy_account function @@ -504,6 +792,7 @@ pub fn deploy_account( factory_address: factory_addr, eoa_signers, webauthn_signer, + session_signer, id: None, provider, } @@ -678,37 +967,30 @@ pub fn add_passkey_to_account( }) } -/// Send a transaction from a smart account using EOA validator +/// Add a session to an already-deployed smart account /// -/// # Arguments -/// * `rpc_url` - The RPC endpoint URL -/// * `bundler_url` - The bundler endpoint URL (e.g., "http://localhost:4337") -/// * `account_address` - The deployed smart account address -/// * `entry_point_address` - The EntryPoint contract address -/// * `eoa_validator_address` - The EOA validator module address -/// * `eoa_private_key` - Private key of the EOA signer (0x-prefixed hex string) -/// * `to_address` - The recipient address for the transaction -/// * `value` - The amount to send (as string, e.g., "1000000000000000000" for 1 ETH) -/// * `data` - Optional calldata as hex string (default: "0x" for simple transfer) +/// This installs the SessionKeyValidator module (if needed) and creates a session. +/// The transaction is authorized by an EOA signer configured in the account's EOA validator. /// -/// # Returns -/// Promise that resolves when the UserOperation is confirmed +/// # Parameters +/// * `config` - SendTransactionConfig with RPC URL, bundler URL, and entry point +/// * `account_address` - The deployed smart account address +/// * `session_payload` - The session specification payload +/// * `session_validator_address` - The SessionKeyValidator module address +/// * `eoa_validator_address` - The EOA validator module address (used for authorization) +/// * `eoa_private_key` - Private key of an EOA signer (hex string) authorized in the EOA validator #[wasm_bindgen] -pub fn send_transaction_eoa( +pub fn add_session_to_account( config: SendTransactionConfig, + account_address: String, + session_payload: SessionPayload, + session_validator_address: String, eoa_validator_address: String, eoa_private_key: String, - account_address: String, - to_address: String, - value: String, - data: Option, ) -> js_sys::Promise { future_to_promise(async move { - console_log!("Starting EOA transaction..."); + console_log!("Adding session to smart account..."); console_log!(" Account: {}", account_address); - console_log!(" To: {}", to_address); - console_log!(" Value: {}", value); - console_log!(" Bundler: {}", config.bundler_url); // Parse addresses let account = match account_address.parse::
() { @@ -731,6 +1013,17 @@ pub fn send_transaction_eoa( } }; + let session_validator = + match session_validator_address.parse::
() { + Ok(addr) => addr, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid session validator address: {}", + e + ))); + } + }; + let eoa_validator = match eoa_validator_address.parse::
() { Ok(addr) => addr, Err(e) => { @@ -741,46 +1034,6 @@ pub fn send_transaction_eoa( } }; - let to = match to_address.parse::
() { - Ok(addr) => addr, - Err(e) => { - return Err(JsValue::from_str(&format!( - "Invalid to address: {}", - e - ))); - } - }; - - // Parse value - let value_u256 = match value.parse::() { - Ok(v) => v, - Err(e) => { - return Err(JsValue::from_str(&format!( - "Invalid value: {}", - e - ))); - } - }; - - // Parse data - let data_bytes = match data { - Some(d) => { - let hex_str = d.trim_start_matches("0x"); - match hex::decode(hex_str) { - Ok(bytes) => Bytes::from(bytes), - Err(e) => { - return Err(JsValue::from_str(&format!( - "Invalid data hex: {}", - e - ))); - } - } - } - None => Bytes::default(), - }; - - console_log!(" Parsed addresses and values successfully"); - // Parse EOA private key let eoa_key = match eoa_private_key .trim_start_matches("0x") @@ -795,20 +1048,14 @@ pub fn send_transaction_eoa( } }; - let eoa_wallet = EthereumWallet::from(eoa_key); - console_log!( - " EOA signer address: {:?}", - eoa_wallet.default_signer().address() - ); + let eoa_wallet = EthereumWallet::from(eoa_key.clone()); // Create transport and provider - let transport = WasmHttpTransport::new(config.rpc_url.clone()); + let transport = WasmHttpTransport::new(config.rpc_url); let client = RpcClient::new(transport.clone(), false); let provider = ProviderBuilder::new().wallet(eoa_wallet).connect_client(client); - console_log!(" Created provider and transport"); - // Create bundler client let bundler_client = { use zksync_sso_erc4337_core::erc4337::bundler::config::BundlerConfig; @@ -816,86 +1063,428 @@ pub fn send_transaction_eoa( zksync_sso_erc4337_core::erc4337::bundler::pimlico::client::BundlerClient::new(config) }; - console_log!(" Created bundler client"); - - // Encode the execution call - use zksync_sso_erc4337_core::erc4337::account::erc7579::{ - Execution, calls::encode_calls, + // Build EOA signature provider for authorizing module install and session creation + use zksync_sso_erc4337_core::erc4337::account::modular_smart_account::signers::eoa::{ + eoa_signature, stub_signature_eoa, }; + use std::sync::Arc; - let call = - Execution { target: to, value: value_u256, data: data_bytes }; - - let calls = vec![call]; - let encoded_calls: Bytes = encode_calls(calls).into(); + let stub_sig = match stub_signature_eoa(eoa_validator) { + Ok(sig) => sig, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Failed to create stub signature: {}", + e + ))); + } + }; - console_log!( - " Encoded call data, calling core send_transaction_eoa..." + let eoa_key_str = format!("0x{}", hex::encode(eoa_key.to_bytes())); + let signature_provider = Arc::new( + move |hash: FixedBytes<32>| -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = eyre::Result, + > + Send, + >, + > { + let eoa_key_hex = eoa_key_str.clone(); + let eoa_validator_addr = eoa_validator; + Box::pin(async move { + eoa_signature(&eoa_key_hex, eoa_validator_addr, hash) + }) + }, ); - match zksync_sso_erc4337_core::erc4337::account::modular_smart_account::send::eoa::eoa_send_transaction(EOASendParams { + let signer = zksync_sso_erc4337_core::erc4337::signer::Signer { + provider: signature_provider, + stub_signature: stub_sig, + }; + + // Ensure SessionKeyValidator module is installed + match zksync_sso_erc4337_core::erc4337::account::erc7579::add_module::add_module( account, + session_validator, entry_point, - call_data: encoded_calls, - nonce_key: None, - paymaster: None, - bundler_client, - provider, - eoa_validator, - private_key_hex: eoa_private_key, - }) + provider.clone(), + bundler_client.clone(), + signer.clone(), + ) .await { Ok(_) => { - console_log!(" Transaction sent successfully!"); - Ok(JsValue::from_str("Transaction sent successfully")) + console_log!(" Session module installed (or already present)"); } Err(e) => { - console_log!(" Error sending transaction: {}", e); - Err(JsValue::from_str(&format!("Failed to send transaction: {}", e))) + console_log!(" Warning: add_module failed (may already be installed): {}", e); } } - }) -} - -/// Prepare a UserOperation for passkey signing with fixed gas limits -/// Returns the hash that needs to be signed by the passkey and the prepared UserOperation data -/// -/// This function creates a stub signature internally, so the caller doesn't need to provide one. -/// The prepared UserOperation data should be stored by the caller and passed to submit_passkey_user_operation -/// after the passkey signature is obtained (stateless design - no global state). -/// -/// # Parameters -/// * `config` - SendTransactionConfig with RPC, bundler, and entry point -/// * `webauthn_validator_address` - Address of the WebAuthn validator module -/// * `account_address` - The smart account address -/// * `to_address` - The recipient address -/// * `value` - Amount to send (as string, e.g., "1000000000000000000" for 1 ETH) -/// * `data` - Optional calldata as hex string -/// -/// # Returns -/// JSON string with format: `{"hash": "0x...", "userOp": {...}}` -/// - hash: The hash that should be signed by the passkey -/// - userOp: The prepared UserOperation data (serialize this and pass to submit_passkey_user_operation) -#[wasm_bindgen] -pub fn prepare_passkey_user_operation( - config: SendTransactionConfig, - webauthn_validator_address: String, - account_address: String, - to_address: String, - value: String, - data: Option, -) -> js_sys::Promise { - future_to_promise(async move { - console_log!( - "Preparing passkey UserOperation with fixed gas (no bundler estimation)..." - ); - console_log!(" Account: {}", account_address); - console_log!(" To: {}", to_address); - console_log!(" Value: {}", value); - // Parse addresses - let account = match account_address.parse::
() { + // Convert SessionPayload to CoreSessionSpec + let signer_addr = match session_payload.signer.parse::
() { + Ok(addr) => addr, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid session signer address: {}", + e + ))); + } + }; + + let expires_at = + match u64::from_str_radix(&session_payload.expires_at, 10) { + Ok(v) => U48::from(v), + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid expires_at value: {}", + e + ))); + } + }; + + let fee_limit_type = + match CoreLimitType::try_from(session_payload.fee_limit_type) { + Ok(t) => t, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid fee limit type: {}", + e + ))); + } + }; + + let fee_limit_value = + match U256::from_str(&session_payload.fee_limit_value) { + Ok(v) => v, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid fee limit value: {}", + e + ))); + } + }; + + let fee_limit_period = + match U48::from_str(&session_payload.fee_limit_period) { + Ok(p) => p, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid fee limit period: {}", + e + ))); + } + }; + + let fee_limit = CoreUsageLimit { + limit_type: fee_limit_type, + limit: fee_limit_value, + period: fee_limit_period, + }; + + let transfer_policies: Result, JsValue> = + session_payload + .transfers + .iter() + .map(|t| { + let target = t.target.parse::
().map_err(|e| { + JsValue::from_str(&format!( + "Invalid transfer target: {}", + e + )) + })?; + let max_value_per_use = + U256::from_str(&t.value_limit_value).map_err(|e| { + JsValue::from_str(&format!( + "Invalid value_limit_value: {}", + e + )) + })?; + let limit_type = CoreLimitType::try_from( + t.value_limit_type, + ) + .map_err(|e| { + JsValue::from_str(&format!( + "Invalid value_limit_type: {}", + e + )) + })?; + let limit = + U256::from_str(&t.value_limit_value).map_err(|e| { + JsValue::from_str(&format!( + "Invalid value_limit_value: {}", + e + )) + })?; + let period = + U48::from_str(&t.value_limit_period).map_err(|e| { + JsValue::from_str(&format!( + "Invalid value_limit_period: {}", + e + )) + })?; + + Ok(CoreTransferSpec { + target, + max_value_per_use, + value_limit: CoreUsageLimit { + limit_type, + limit, + period, + }, + }) + }) + .collect(); + + let transfer_policies = transfer_policies?; + + let session_spec = CoreSessionSpec { + signer: signer_addr, + expires_at, + fee_limit, + call_policies: vec![], + transfer_policies, + }; + + console_log!(" Creating session via core create_session..."); + + match zksync_sso_erc4337_core::erc4337::account::modular_smart_account::session::create::create_session( + account, + session_spec, + entry_point, + session_validator, + bundler_client, + provider, + signer, + ) + .await + { + Ok(_) => Ok(JsValue::from_str("Session created successfully")), + Err(e) => Err(JsValue::from_str(&format!( + "Failed to create session: {}", + e + ))), + } + }) +} + +/// Send a transaction from a smart account using EOA validator +/// +/// # Arguments +/// * `rpc_url` - The RPC endpoint URL +/// * `bundler_url` - The bundler endpoint URL (e.g., "http://localhost:4337") +/// * `account_address` - The deployed smart account address +/// * `entry_point_address` - The EntryPoint contract address +/// * `eoa_validator_address` - The EOA validator module address +/// * `eoa_private_key` - Private key of the EOA signer (0x-prefixed hex string) +/// * `to_address` - The recipient address for the transaction +/// * `value` - The amount to send (as string, e.g., "1000000000000000000" for 1 ETH) +/// * `data` - Optional calldata as hex string (default: "0x" for simple transfer) +/// +/// # Returns +/// Promise that resolves when the UserOperation is confirmed +#[wasm_bindgen] +pub fn send_transaction_eoa( + config: SendTransactionConfig, + eoa_validator_address: String, + eoa_private_key: String, + account_address: String, + to_address: String, + value: String, + data: Option, +) -> js_sys::Promise { + future_to_promise(async move { + console_log!("Starting EOA transaction..."); + console_log!(" Account: {}", account_address); + console_log!(" To: {}", to_address); + console_log!(" Value: {}", value); + console_log!(" Bundler: {}", config.bundler_url); + + // Parse addresses + let account = match account_address.parse::
() { + Ok(addr) => addr, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid account address: {}", + e + ))); + } + }; + + let entry_point = match config.entry_point_address.parse::
() { + Ok(addr) => addr, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid entry point address: {}", + e + ))); + } + }; + + let eoa_validator = match eoa_validator_address.parse::
() { + Ok(addr) => addr, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid EOA validator address: {}", + e + ))); + } + }; + + let to = match to_address.parse::
() { + Ok(addr) => addr, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid to address: {}", + e + ))); + } + }; + + // Parse value + let value_u256 = match value.parse::() { + Ok(v) => v, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid value: {}", + e + ))); + } + }; + + // Parse data + let data_bytes = match data { + Some(d) => { + let hex_str = d.trim_start_matches("0x"); + match hex::decode(hex_str) { + Ok(bytes) => Bytes::from(bytes), + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid data hex: {}", + e + ))); + } + } + } + None => Bytes::default(), + }; + + console_log!(" Parsed addresses and values successfully"); + + // Parse EOA private key + let eoa_key = match eoa_private_key + .trim_start_matches("0x") + .parse::() + { + Ok(signer) => signer, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid EOA private key: {}", + e + ))); + } + }; + + let eoa_wallet = EthereumWallet::from(eoa_key); + console_log!( + " EOA signer address: {:?}", + eoa_wallet.default_signer().address() + ); + + // Create transport and provider + let transport = WasmHttpTransport::new(config.rpc_url.clone()); + let client = RpcClient::new(transport.clone(), false); + let provider = + ProviderBuilder::new().wallet(eoa_wallet).connect_client(client); + + console_log!(" Created provider and transport"); + + // Create bundler client + let bundler_client = { + use zksync_sso_erc4337_core::erc4337::bundler::config::BundlerConfig; + let config = BundlerConfig::new(config.bundler_url); + zksync_sso_erc4337_core::erc4337::bundler::pimlico::client::BundlerClient::new(config) + }; + + console_log!(" Created bundler client"); + + // Encode the execution call + use zksync_sso_erc4337_core::erc4337::account::erc7579::{ + Execution, calls::encode_calls, + }; + + let call = + Execution { target: to, value: value_u256, data: data_bytes }; + + let calls = vec![call]; + let encoded_calls: Bytes = encode_calls(calls).into(); + + console_log!( + " Encoded call data, calling core send_transaction_eoa..." + ); + + match zksync_sso_erc4337_core::erc4337::account::modular_smart_account::send::eoa::eoa_send_transaction(EOASendParams { + account, + entry_point, + call_data: encoded_calls, + nonce_key: None, + paymaster: None, + bundler_client, + provider, + eoa_validator, + private_key_hex: eoa_private_key, + }) + .await + { + Ok(_) => { + console_log!(" Transaction sent successfully!"); + Ok(JsValue::from_str("Transaction sent successfully")) + } + Err(e) => { + console_log!(" Error sending transaction: {}", e); + Err(JsValue::from_str(&format!("Failed to send transaction: {}", e))) + } + } + }) +} + +/// Prepare a UserOperation for passkey signing with fixed gas limits +/// Returns the hash that needs to be signed by the passkey and the prepared UserOperation data +/// +/// This function creates a stub signature internally, so the caller doesn't need to provide one. +/// The prepared UserOperation data should be stored by the caller and passed to submit_passkey_user_operation +/// after the passkey signature is obtained (stateless design - no global state). +/// +/// # Parameters +/// * `config` - SendTransactionConfig with RPC, bundler, and entry point +/// * `webauthn_validator_address` - Address of the WebAuthn validator module +/// * `account_address` - The smart account address +/// * `to_address` - The recipient address +/// * `value` - Amount to send (as string, e.g., "1000000000000000000" for 1 ETH) +/// * `data` - Optional calldata as hex string +/// +/// # Returns +/// JSON string with format: `{"hash": "0x...", "userOp": {...}}` +/// - hash: The hash that should be signed by the passkey +/// - userOp: The prepared UserOperation data (serialize this and pass to submit_passkey_user_operation) +#[wasm_bindgen] +pub fn prepare_passkey_user_operation( + config: SendTransactionConfig, + webauthn_validator_address: String, + account_address: String, + to_address: String, + value: String, + data: Option, +) -> js_sys::Promise { + future_to_promise(async move { + console_log!( + "Preparing passkey UserOperation with fixed gas (no bundler estimation)..." + ); + console_log!(" Account: {}", account_address); + console_log!(" To: {}", to_address); + console_log!(" Value: {}", value); + + // Parse addresses + let account = match account_address.parse::
() { Ok(addr) => addr, Err(e) => { return Err(JsValue::from_str(&format!( @@ -1274,64 +1863,380 @@ pub fn submit_passkey_user_operation( } }; - console_log!(" Signature length: {} bytes", signature.len()); + console_log!(" Signature length: {} bytes", signature.len()); + + // Prepend validator address to signature (ModularSmartAccount expects first 20 bytes to be validator) + let mut full_signature = Vec::new(); + full_signature.extend_from_slice(validator_address.as_slice()); // 20 bytes + full_signature.extend_from_slice(&signature); // ABI-encoded passkey signature + + console_log!( + " Full signature length: {} bytes (20 validator + {} passkey)", + full_signature.len(), + signature.len() + ); + + // Log signature breakdown for debugging + console_log!(" Validator address: {:?}", validator_address); + console_log!( + " First 32 bytes of passkey signature: {:?}", + &signature.as_ref().get(0..32.min(signature.len())) + ); + console_log!( + " Last 32 bytes of passkey signature: {:?}", + signature.as_ref().get(signature.len().saturating_sub(32)..) + ); + + // Create UserOperation with the real signature + let user_op = AlloyPackedUserOperation { + sender, + nonce, + call_data, + signature: Bytes::from(full_signature.clone()), + paymaster: None, + paymaster_verification_gas_limit: None, + paymaster_data: None, + paymaster_post_op_gas_limit: None, + call_gas_limit, + max_priority_fee_per_gas, + max_fee_per_gas, + pre_verification_gas, + verification_gas_limit, + factory: None, + factory_data: None, + }; + + // Log UserOperation details before submission + console_log!(" UserOperation details:"); + console_log!(" sender: {:?}", user_op.sender); + console_log!(" nonce: {}", user_op.nonce); + console_log!(" callData length: {}", user_op.call_data.len()); + console_log!(" signature length: {}", user_op.signature.len()); + console_log!(" callGasLimit: {}", user_op.call_gas_limit); + console_log!( + " verificationGasLimit: {}", + user_op.verification_gas_limit + ); + console_log!( + " preVerificationGas: {}", + user_op.pre_verification_gas + ); + + // Create bundler client + let bundler_client = { + use zksync_sso_erc4337_core::erc4337::bundler::config::BundlerConfig; + let config = BundlerConfig::new(config.bundler_url); + zksync_sso_erc4337_core::erc4337::bundler::pimlico::client::BundlerClient::new(config) + }; + + // Parse entry point address for bundler call + let entry_point = match config.entry_point_address.parse::
() { + Ok(addr) => addr, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid entry point address: {}", + e + ))); + } + }; + + // Submit UserOperation + use zksync_sso_erc4337_core::erc4337::bundler::Bundler; + + match bundler_client.send_user_operation(entry_point, user_op).await { + Ok(user_op_hash) => { + console_log!(" UserOperation submitted: {:?}", user_op_hash); + let hash_for_display = user_op_hash.clone(); + + // Wait for receipt + match bundler_client + .wait_for_user_operation_receipt(user_op_hash) + .await + { + Ok(receipt) => { + console_log!(" UserOperation confirmed!"); + console_log!(" Receipt: {:?}", receipt); + Ok(JsValue::from_str(&format!( + "Transaction confirmed! UserOp hash: {:?}", + hash_for_display + ))) + } + Err(e) => { + console_log!(" Error waiting for receipt: {}", e); + Err(JsValue::from_str(&format!( + "UserOperation submitted but failed to get receipt: {}", + e + ))) + } + } + } + Err(e) => { + console_log!(" Error submitting UserOperation: {}", e); + Err(JsValue::from_str(&format!( + "Failed to submit UserOperation: {}", + e + ))) + } + } + }) +} + +/// Send a transaction using a session key +/// +/// This function sends a transaction signed by a session key validator. +/// It uses a keyed nonce based on the session signer address and creates +/// a session signature that includes the SessionSpec and period IDs. +/// +/// # Parameters +/// * `config` - SendTransactionConfig with RPC, bundler, and entry point +/// * `session_validator_address` - Address of the Session Key validator module +/// * `account_address` - The smart account address +/// * `to_address` - The recipient address +/// * `value` - Amount to send (as string, e.g., "1000000000000000000" for 1 ETH) +/// * `data` - Optional calldata as hex string +/// * `session_private_key` - The session key private key (hex string) +/// * `session_payload` - The SessionPayload containing session configuration +#[wasm_bindgen] +pub fn send_transaction_session( + config: SendTransactionConfig, + session_validator_address: String, + account_address: String, + to_address: String, + value: String, + data: Option, + session_private_key: String, + session_payload: SessionPayload, +) -> js_sys::Promise { + future_to_promise(async move { + console_log!("Starting Session transaction..."); + console_log!(" Account: {}", account_address); + console_log!(" To: {}", to_address); + console_log!(" Value: {}", value); + console_log!(" Bundler: {}", config.bundler_url); + + // Parse addresses + let account = match account_address.parse::
() { + Ok(addr) => addr, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid account address: {}", + e + ))); + } + }; + + let entry_point = match config.entry_point_address.parse::
() { + Ok(addr) => addr, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid entry point address: {}", + e + ))); + } + }; + + let session_validator = + match session_validator_address.parse::
() { + Ok(addr) => addr, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid session validator address: {}", + e + ))); + } + }; + + let to = match to_address.parse::
() { + Ok(addr) => addr, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid to address: {}", + e + ))); + } + }; + + // Parse value + let value_u256 = match value.parse::() { + Ok(v) => v, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid value: {}", + e + ))); + } + }; + + // Parse data + let data_bytes = match data { + Some(d) => { + let hex_str = d.trim_start_matches("0x"); + match hex::decode(hex_str) { + Ok(bytes) => Bytes::from(bytes), + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid data hex: {}", + e + ))); + } + } + } + None => Bytes::default(), + }; + + console_log!(" Parsed addresses and values successfully"); + + // Parse session private key to get signer address + let session_signer_address = match session_private_key + .trim_start_matches("0x") + .parse::() + { + Ok(signer) => { + let addr = signer.address(); + console_log!(" Session signer address: {:?}", addr); + addr + } + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid session private key: {}", + e + ))); + } + }; + + // Convert SessionPayload to SessionSpec + let signer_addr = match session_payload.signer.parse::
() { + Ok(addr) => addr, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid session signer address: {}", + e + ))); + } + }; + + let expires_at = + match u64::from_str_radix(&session_payload.expires_at, 10) { + Ok(v) => U48::from(v), + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid expires_at value: {}", + e + ))); + } + }; + + let fee_limit_type = + match CoreLimitType::try_from(session_payload.fee_limit_type) { + Ok(t) => t, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid fee limit type: {}", + e + ))); + } + }; + + let fee_limit_value = + match U256::from_str(&session_payload.fee_limit_value) { + Ok(v) => v, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid fee limit value: {}", + e + ))); + } + }; + + let fee_limit_period = + match U48::from_str(&session_payload.fee_limit_period) { + Ok(p) => p, + Err(e) => { + return Err(JsValue::from_str(&format!( + "Invalid fee limit period: {}", + e + ))); + } + }; + + let fee_limit = CoreUsageLimit { + limit_type: fee_limit_type, + limit: fee_limit_value, + period: fee_limit_period, + }; + + let transfer_policies: Result, JsValue> = + session_payload + .transfers + .iter() + .map(|t| { + let target = t.target.parse::
().map_err(|e| { + JsValue::from_str(&format!( + "Invalid transfer target: {}", + e + )) + })?; + let max_value_per_use = + U256::from_str(&t.value_limit_value).map_err(|e| { + JsValue::from_str(&format!( + "Invalid value_limit_value: {}", + e + )) + })?; + let limit_type = CoreLimitType::try_from( + t.value_limit_type, + ) + .map_err(|e| { + JsValue::from_str(&format!( + "Invalid value_limit_type: {}", + e + )) + })?; + let limit = + U256::from_str(&t.value_limit_value).map_err(|e| { + JsValue::from_str(&format!( + "Invalid value_limit_value: {}", + e + )) + })?; + let period = + U48::from_str(&t.value_limit_period).map_err(|e| { + JsValue::from_str(&format!( + "Invalid value_limit_period: {}", + e + )) + })?; + + Ok(CoreTransferSpec { + target, + max_value_per_use, + value_limit: CoreUsageLimit { + limit_type, + limit, + period, + }, + }) + }) + .collect(); - // Prepend validator address to signature (ModularSmartAccount expects first 20 bytes to be validator) - let mut full_signature = Vec::new(); - full_signature.extend_from_slice(validator_address.as_slice()); // 20 bytes - full_signature.extend_from_slice(&signature); // ABI-encoded passkey signature + let transfer_policies = transfer_policies?; - console_log!( - " Full signature length: {} bytes (20 validator + {} passkey)", - full_signature.len(), - signature.len() - ); + let session_spec = CoreSessionSpec { + signer: signer_addr, + expires_at, + fee_limit, + call_policies: vec![], + transfer_policies, + }; - // Log signature breakdown for debugging - console_log!(" Validator address: {:?}", validator_address); - console_log!( - " First 32 bytes of passkey signature: {:?}", - &signature.as_ref().get(0..32.min(signature.len())) - ); - console_log!( - " Last 32 bytes of passkey signature: {:?}", - signature.as_ref().get(signature.len().saturating_sub(32)..) - ); + console_log!(" Converted session payload to SessionSpec"); - // Create UserOperation with the real signature - let user_op = AlloyPackedUserOperation { - sender, - nonce, - call_data, - signature: Bytes::from(full_signature.clone()), - paymaster: None, - paymaster_verification_gas_limit: None, - paymaster_data: None, - paymaster_post_op_gas_limit: None, - call_gas_limit, - max_priority_fee_per_gas, - max_fee_per_gas, - pre_verification_gas, - verification_gas_limit, - factory: None, - factory_data: None, - }; + // Create transport and provider (without wallet - session signing is custom) + let transport = WasmHttpTransport::new(config.rpc_url.clone()); + let client = RpcClient::new(transport.clone(), false); + let provider = ProviderBuilder::new().connect_client(client); - // Log UserOperation details before submission - console_log!(" UserOperation details:"); - console_log!(" sender: {:?}", user_op.sender); - console_log!(" nonce: {}", user_op.nonce); - console_log!(" callData length: {}", user_op.call_data.len()); - console_log!(" signature length: {}", user_op.signature.len()); - console_log!(" callGasLimit: {}", user_op.call_gas_limit); - console_log!( - " verificationGasLimit: {}", - user_op.verification_gas_limit - ); - console_log!( - " preVerificationGas: {}", - user_op.pre_verification_gas - ); + console_log!(" Created provider and transport"); // Create bundler client let bundler_client = { @@ -1340,51 +2245,95 @@ pub fn submit_passkey_user_operation( zksync_sso_erc4337_core::erc4337::bundler::pimlico::client::BundlerClient::new(config) }; - // Parse entry point address for bundler call - let entry_point = match config.entry_point_address.parse::
() { - Ok(addr) => addr, + console_log!(" Created bundler client"); + + // Encode the execution call + use zksync_sso_erc4337_core::erc4337::account::erc7579::{ + Execution, calls::encode_calls, + }; + + let call = + Execution { target: to, value: value_u256, data: data_bytes }; + + let calls = vec![call]; + let encoded_calls: Bytes = encode_calls(calls).into(); + + console_log!(" Encoded call data, preparing session transaction..."); + + // Use keyed nonce for session + use zksync_sso_erc4337_core::erc4337::account::modular_smart_account::session::send::keyed_nonce; + let nonce_key = keyed_nonce(session_signer_address); + console_log!(" Session keyed nonce: {}", nonce_key); + + // Send transaction with session signature + use std::sync::Arc; + use zksync_sso_erc4337_core::erc4337::{ + account::modular_smart_account::{ + send::{SendParams, send_transaction}, + session::signature::session_signature, + }, + signer::Signer, + }; + + // Create stub signature for gas estimation + let stub_sig = match session_signature( + STUB_PRIVATE_KEY, + session_validator, + &session_spec, + FixedBytes::default(), + ) { + Ok(sig) => sig, Err(e) => { return Err(JsValue::from_str(&format!( - "Invalid entry point address: {}", + "Failed to create stub signature: {}", e ))); } }; - // Submit UserOperation - use zksync_sso_erc4337_core::erc4337::bundler::Bundler; + // Create signature provider + let private_key = session_private_key.clone(); + let signature_provider = Arc::new( + move |hash: FixedBytes<32>| -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = eyre::Result, + > + Send, + >, + > { + let pk = private_key.clone(); + let sess_validator = session_validator; + let spec = session_spec.clone(); + Box::pin(async move { + session_signature(&pk, sess_validator, &spec, hash) + }) + }, + ); - match bundler_client.send_user_operation(entry_point, user_op).await { - Ok(user_op_hash) => { - console_log!(" UserOperation submitted: {:?}", user_op_hash); - let hash_for_display = user_op_hash.clone(); + let signer = + Signer { provider: signature_provider, stub_signature: stub_sig }; - // Wait for receipt - match bundler_client - .wait_for_user_operation_receipt(user_op_hash) - .await - { - Ok(receipt) => { - console_log!(" UserOperation confirmed!"); - console_log!(" Receipt: {:?}", receipt); - Ok(JsValue::from_str(&format!( - "Transaction confirmed! UserOp hash: {:?}", - hash_for_display - ))) - } - Err(e) => { - console_log!(" Error waiting for receipt: {}", e); - Err(JsValue::from_str(&format!( - "UserOperation submitted but failed to get receipt: {}", - e - ))) - } - } + match send_transaction(SendParams { + account, + entry_point, + factory_payload: None, + call_data: encoded_calls, + nonce_key: Some(nonce_key), + paymaster: None, + bundler_client, + provider, + signer, + }) + .await + { + Ok(_) => { + console_log!(" Session transaction sent successfully!"); + Ok(JsValue::from_str("Session transaction sent successfully")) } Err(e) => { - console_log!(" Error submitting UserOperation: {}", e); + console_log!(" Error sending session transaction: {}", e); Err(JsValue::from_str(&format!( - "Failed to submit UserOperation: {}", + "Failed to send session transaction: {}", e ))) } @@ -1704,6 +2653,115 @@ impl Client { #[cfg(test)] mod tests { + /// Test deploying an account with a session installed at creation (deploy-with-session) + #[tokio::test] + async fn test_wasm_deploy_with_session() -> eyre::Result<()> { + use zksync_sso_erc4337_core::erc4337::account::modular_smart_account::session::session_lib::session_spec::{ + SessionSpec, transfer_spec::TransferSpec, usage_limit::UsageLimit, limit_type::LimitType, + }; + use alloy::primitives::{U256, aliases::U48, address}; + + let signer_private_key = "0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6".to_string(); + let config = + TestInfraConfig { signer_private_key: signer_private_key.clone() }; + let ( + _, + anvil_instance, + provider, + contracts, + _signer_private_key, + bundler, + bundler_client, + ) = start_anvil_and_deploy_contracts_and_start_bundler_with_config( + &config, + ) + .await?; + + let factory_address = contracts.account_factory; + let entry_point_address = + address!("0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108"); + let eoa_validator_address = contracts.eoa_validator; + let session_validator_address = contracts.session_validator; + + // Use the same EOA signer address as core tests to match validator expectations + let signers = + vec![address!("0xa0Ee7A142d267C1f36714E4a8F75612F20a79720")]; + let eoa_signers = zksync_sso_erc4337_core::erc4337::account::modular_smart_account::deploy::EOASigners { + addresses: signers.clone(), + validator_address: eoa_validator_address, + }; + + // Session spec for deploy-with-session + let session_signer_address = signers[0]; + let transfer_target = signers[0]; + let session_spec = SessionSpec { + signer: session_signer_address, + expires_at: U48::from(2088558400u64), + fee_limit: UsageLimit { + limit_type: LimitType::Lifetime, + limit: U256::from(1_000_000_000_000_000_000u64), + period: U48::from(0), + }, + call_policies: vec![], + transfer_policies: vec![TransferSpec { + target: transfer_target, + max_value_per_use: U256::from(1u64), + value_limit: UsageLimit { + limit_type: LimitType::Unlimited, + limit: U256::from(0), + period: U48::from(0), + }, + }], + }; + + let session_signer = zksync_sso_erc4337_core::erc4337::account::modular_smart_account::deploy::SessionSigner { + session_spec, + validator_address: session_validator_address, + }; + + // Deploy account with session installed at creation + let account_address = zksync_sso_erc4337_core::erc4337::account::modular_smart_account::deploy::deploy_account( + zksync_sso_erc4337_core::erc4337::account::modular_smart_account::deploy::DeployAccountParams { + factory_address, + eoa_signers: Some(eoa_signers), + webauthn_signer: None, + session_signer: Some(session_signer), + id: None, + provider: provider.clone(), + } + ).await?; + + // Fund the account + { + let fund_tx = alloy::providers::Provider::send_transaction( + &provider, + alloy::rpc::types::TransactionRequest::default() + .to(account_address) + .value(U256::from(10000000000000000000u64)), + ) + .await? + .get_receipt() + .await?; + } + + // Verify session module is installed + let is_installed = is_module_installed( + session_validator_address, + account_address, + provider.clone(), + ) + .await?; + eyre::ensure!( + is_installed, + "Session module is not installed after deploy-with-session" + ); + + // Optionally: verify session spec (out of scope for this smoke test) + + drop(anvil_instance); + drop(bundler); + Ok(()) + } use alloy::{ primitives::{Bytes, FixedBytes, U256, address, bytes, fixed_bytes}, providers::Provider, @@ -1784,6 +2842,7 @@ mod tests { factory_address, eoa_signers: None, // No EOA signer webauthn_signer: Some(webauthn_signer), + session_signer: None, id: None, provider: provider.clone(), }) @@ -2062,4 +3121,192 @@ mod tests { } } } + + /// Test deploying an account, adding a session, and verifying session module + #[tokio::test] + async fn test_wasm_session_add_and_verify() -> eyre::Result<()> { + use zksync_sso_erc4337_core::erc4337::account::modular_smart_account::session::session_lib::session_spec::{ + SessionSpec, transfer_spec::TransferSpec, usage_limit::UsageLimit, limit_type::LimitType, + }; + use alloy::primitives::{U256, aliases::U48}; + + let signer_private_key = "0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6".to_string(); + let config = + TestInfraConfig { signer_private_key: signer_private_key.clone() }; + let ( + _, + anvil_instance, + provider, + contracts, + _signer_private_key, + bundler, + bundler_client, + ) = start_anvil_and_deploy_contracts_and_start_bundler_with_config( + &config, + ) + .await?; + + let factory_address = contracts.account_factory; + let entry_point_address = + address!("0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108"); + let eoa_validator_address = contracts.eoa_validator; + let session_validator_address = contracts.session_validator; + + // Deploy account with EOA validator + // Use the same EOA signer address as core tests to match validator expectations + let signers = + vec![address!("0xa0Ee7A142d267C1f36714E4a8F75612F20a79720")]; + let eoa_signers = zksync_sso_erc4337_core::erc4337::account::modular_smart_account::deploy::EOASigners { + addresses: signers, + validator_address: eoa_validator_address, + }; + + let account_address = deploy_account(zksync_sso_erc4337_core::erc4337::account::modular_smart_account::deploy::DeployAccountParams { + factory_address, + eoa_signers: Some(eoa_signers), + webauthn_signer: None, + session_signer: None, + id: None, + provider: provider.clone(), + }).await?; + + // Fund the account + { + let fund_tx = alloy::providers::Provider::send_transaction( + &provider, + alloy::rpc::types::TransactionRequest::default() + .to(account_address) + .value(U256::from(10000000000000000000u64)), + ) + .await? + .get_receipt() + .await?; + } + + // Install session module and add a session to the account + { + use std::sync::Arc; + use zksync_sso_erc4337_core::erc4337::account::{ + erc7579::add_module::add_module, + modular_smart_account::signers::eoa::{ + eoa_signature, stub_signature_eoa, + }, + }; + + // Build EOA signer matching validator + let stub_sig = stub_signature_eoa(eoa_validator_address)?; + let signer_private_key = signer_private_key.clone(); + let signature_provider = Arc::new( + move |hash: FixedBytes<32>| -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = eyre::Result, + > + Send, + >, + > { + let pk = signer_private_key.clone(); + let validator = eoa_validator_address; + Box::pin(async move { + eoa_signature(&pk, validator, hash) + }) + }, + ); + + let signer = zksync_sso_erc4337_core::erc4337::signer::Signer { + provider: signature_provider, + stub_signature: stub_sig, + }; + + // Install session module + add_module( + account_address, + session_validator_address, + entry_point_address, + provider.clone(), + bundler_client.clone(), + signer.clone(), + ) + .await?; + + // Verify installed + let is_installed = is_module_installed( + session_validator_address, + account_address, + provider.clone(), + ) + .await?; + eyre::ensure!(is_installed, "Session module is not installed"); + } + + // Create session spec + // Match core test parameters for session signer and transfer target + let session_signer_address = + address!("0xa0Ee7A142d267C1f36714E4a8F75612F20a79720"); + let transfer_target = + address!("0xa0Ee7A142d267C1f36714E4a8F75612F20a79720"); + let session_spec = SessionSpec { + signer: session_signer_address, + // Use a concrete timestamp similar to core tests + expires_at: U48::from(2088558400u64), + // Align fee limit with core test: Lifetime limit of 1 ETH + fee_limit: UsageLimit { + limit_type: LimitType::Lifetime, + limit: U256::from(1_000_000_000_000_000_000u64), + period: U48::from(0), + }, + call_policies: vec![], + // Align transfer policy with core test: per-use max 1 wei, unlimited total + transfer_policies: vec![TransferSpec { + target: transfer_target, + max_value_per_use: U256::from(1u64), + value_limit: UsageLimit { + limit_type: LimitType::Unlimited, + limit: U256::from(0), + period: U48::from(0), + }, + }], + }; + + // Create session via core API (preferred for Rust tests) + zksync_sso_erc4337_core::erc4337::account::modular_smart_account::session::create::create_session( + account_address, + session_spec, + entry_point_address, + session_validator_address, + bundler_client.clone(), + provider.clone(), + { + use std::sync::Arc; + use zksync_sso_erc4337_core::erc4337::account::modular_smart_account::signers::eoa::{ + eoa_signature, stub_signature_eoa, + }; + let stub_sig = stub_signature_eoa(eoa_validator_address)?; + let signer_private_key = signer_private_key.clone(); + let signature_provider = Arc::new( + move |hash: FixedBytes<32>| -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = eyre::Result, + > + Send, + >, + > { + let pk = signer_private_key.clone(); + let validator = eoa_validator_address; + Box::pin(async move { + eoa_signature(&pk, validator, hash) + }) + }, + ); + zksync_sso_erc4337_core::erc4337::signer::Signer { provider: signature_provider, stub_signature: stub_sig } + }, + ) + .await?; + + // Verify session module is installed + // Module already installed above; if create_session succeeded, test passes + + drop(anvil_instance); + drop(bundler); + Ok(()) + } } diff --git a/packages/sdk-platforms/rust/zksync-sso/crates/sdk/src/utils/session/period_ids_for_transaction.rs b/packages/sdk-platforms/rust/zksync-sso/crates/sdk/src/utils/session/period_ids_for_transaction.rs index 2b4e8a369..da3355bb0 100644 --- a/packages/sdk-platforms/rust/zksync-sso/crates/sdk/src/utils/session/period_ids_for_transaction.rs +++ b/packages/sdk-platforms/rust/zksync-sso/crates/sdk/src/utils/session/period_ids_for_transaction.rs @@ -20,20 +20,27 @@ use log::debug; /// /// # Errors /// Returns an error if the transaction doesn't fit any policy +#[cfg(target_arch = "wasm32")] +fn current_unix_timestamp_u64() -> u64 { + // Avoid requiring JS Date import in wasm; use 0 as a neutral epoch reference. + 0 +} + +#[cfg(not(target_arch = "wasm32"))] +fn current_unix_timestamp_u64() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() +} + pub fn get_period_ids_for_transaction( session_config: &SessionSpec, target: Address, selector: Option>, timestamp: Option, ) -> eyre::Result> { - let timestamp = timestamp.unwrap_or_else(|| { - U64::from( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(), - ) - }); + let timestamp = timestamp.unwrap_or_else(|| U64::from(current_unix_timestamp_u64())); let get_id = |usage_limit: &UsageLimit| -> U64 { let limit_type: LimitType = usage_limit.limitType.try_into().unwrap(); diff --git a/packages/sdk-platforms/web/SESSION_IMPLEMENTATION_PROGRESS.md b/packages/sdk-platforms/web/SESSION_IMPLEMENTATION_PROGRESS.md new file mode 100644 index 000000000..ec94ab0a6 --- /dev/null +++ b/packages/sdk-platforms/web/SESSION_IMPLEMENTATION_PROGRESS.md @@ -0,0 +1,93 @@ +# Session Web SDK Implementation - Progress Report + +## Summary + +Completed Phase 1.1-1.3 of session support implementation, adding WASM bindings for session configuration to enable gasless transactions with defined limits in the Web SDK. + +## Completed Work + +### ✅ Phase 1: WASM Bindings (Rust FFI Layer) + +#### 1. Session-Related WASM Types Added + +**File**: `packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-ffi-web/src/lib.rs` + +**New Structs**: +- `TransferPayload` - Represents transfer permissions with value limits +- `SessionPayload` - Complete session configuration with fee limits and transfers + +#### 2. DeployAccountConfig Updated +- Added `session_validator_address: Option` field +- Added getter method +- Updated constructor to accept session validator address + +#### 3. deploy_account Function Signature Updated +- Added `session_payload: Option` parameter +- Updated documentation +- Added warning message when session payload is provided (core support pending) + +#### 4. Core Type Imports Added +Imported session types for future conversion logic: +- `SessionSpec` +- `TransferSpec` +- `UsageLimit` +- `LimitType` + +## Build Status + +✅ **Successfully compiles** for `wasm32-unknown-unknown` target with only minor unused import warnings (expected at this stage). + +## Current Limitation + +⚠️ **Core Support Required**: The core `deploy_account` function does not yet support session installation during deployment. + +**Next Steps Options**: + +**Option A** (Recommended): Implement post-deployment session addition +- Create `add_session_to_account()` function (similar to `add_passkey_to_account`) +- Works with existing core functions +- More flexible session management + +**Option B**: Update core deploy function +- Add session support to core `DeployAccountParams` +- Requires changes in `zksync-sso-erc4337-core` +- Enables session installation during initial deployment + +## Files Modified + +1. `packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-ffi-web/src/lib.rs` + - Added `TransferPayload` and `SessionPayload` structs + - Updated `DeployAccountConfig` + - Updated `deploy_account` signature + - Added core session type imports + - Added TODO comment for pending core support + +## Documentation Created + +1. `SESSION_WEB_SDK_PLAN.md` - Complete 5-phase implementation plan +2. `SESSION_IMPLEMENTATION_PROGRESS.md` - This progress report + +## Next Actions + +1. **Decide approach**: Post-deployment vs. during-deployment session installation +2. **Implement chosen approach**: + - If post-deployment: Create `add_session_to_account()` function + - If during-deployment: Update core `deploy_account()` function +3. **Export types in TypeScript** (Phase 2) +4. **Create Vue UI components** (Phase 3) +5. **Implement session transaction signing** (Phase 4) +6. **Add comprehensive tests** (Phase 5) + +## Testing Plan (TODO) + +- [ ] Deploy account with session +- [ ] Send transaction using session +- [ ] Verify session expiration enforcement +- [ ] Verify fee limit enforcement +- [ ] Verify value limit enforcement +- [ ] Multiple transfers within limits +- [ ] Combined EOA + Passkey + Session + +## References + +See `SESSION_WEB_SDK_PLAN.md` for complete implementation details. diff --git a/packages/sdk-platforms/web/SESSION_WEB_SDK_PLAN.md b/packages/sdk-platforms/web/SESSION_WEB_SDK_PLAN.md new file mode 100644 index 000000000..6d7647974 --- /dev/null +++ b/packages/sdk-platforms/web/SESSION_WEB_SDK_PLAN.md @@ -0,0 +1,485 @@ +# Session Support for Web SDK - Implementation Plan + +## Status Summary (Updated Nov 6, 2025) + +- ✅ **Phase 1**: Rust/WASM FFI Layer - COMPLETED + - Session types, deploy-with-session, post-deploy installation + - FFI tests: 4/4 passing +- ✅ **Phase 2**: TypeScript/Web SDK Updates - COMPLETED + - Types, helpers, unit tests, e2e tests for installation + - Unit tests: 8/8 passing + - E2E tests: 4/4 passing (including session install flows) +- ✅ **Phase 3**: Vue Component Updates - COMPLETED + - SessionConfig component created and integrated + - Deploy and post-deploy UI functional +- ⏳ **Phase 4**: Session Transaction Sending - NEXT PRIORITY + - Need: FFI functions, TS wrappers, UI updates, e2e tests +- ⏳ **Phase 5**: Testing & Validation - PARTIAL + - Installation flows validated ✅ + - Transaction sending and limit enforcement pending Phase 4 + +**Recent Achievements**: + +- Both session install flows (deploy-with-session and post-deploy) implemented + and tested +- End-to-end Playwright tests passing for session installation +- Reusable SessionConfig Vue component extracted +- Documentation updated for contracts.json configuration +- All core session infrastructure in place for Web SDK + +**Next Steps**: + +Phase 4 implementation to enable sending transactions signed by session keys, +with proper limit enforcement and validation. + +## Overview + +Add session functionality to the Web SDK to enable gasless transactions within defined +limits. Sessions allow users to authorize specific operations (transfers, contract +calls) with limits on fees and values, signed by a separate session key. + +## Architecture + +### Session Structure + +```typescript +{ + signer: Address, // Session key address + expiresAt: U48, // Unix timestamp + feeLimit: UsageLimit, // Max fees session can pay + transfers: [TransferSpec] // Array of allowed transfers +} + +TransferSpec { + to: Address, // Target address + valueLimit: U256 // Max ETH per transfer +} + +UsageLimit { + limitType: 'unlimited' | 'lifetime' | 'allowance', + limit: U256, // Total limit amount + period: U48 // Period for allowance type +} +``` + +## Phase 1: Rust/WASM FFI Layer Updates ✅ (Completed Nov 6, 2025) + +Phase 1 is complete. We implemented the session WASM types, added both post-deploy +and deploy-with-session flows to the FFI, and validated with automated tests +mirroring core. The FFI and Rust core now support installing a session at account +creation (deploy-with-session) as well as post-deploy. All flows are covered by +tests. + +### 1.1 Create Session-Related WASM Types ✅ + +**File**: `packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-ffi-web/src/lib.rs` + +**Status**: COMPLETED + +- Added `TransferPayload` struct with getters +- Added `SessionPayload` struct with getters +- Types match core session specification + +### 1.2 Update `DeployAccountConfig` ✅ + +**Status**: COMPLETED + +- Added `session_validator_address: Option` field +- Added getter method +- Updated constructor + +### 1.3 Update `deploy_account` Function Signature ✅ + +**Status**: COMPLETED + +- Added `session_payload: Option` parameter +- Updated documentation + +### 1.4 Add Session Support to Core Deploy Function ✅ + +**Status**: COMPLETED + +Deploy-with-session is now supported and tested. The recommended path is to install +the SessionKeyValidator and create a session either during deployment or immediately +after. Both flows are validated in FFI and core tests. + +### 1.5 Alternative: Add Session Post-Deployment 🔄 + +**Status**: COMPLETED + +Instead of adding session during deployment, +we can add a function to install a session module after deployment: + +```rust +#[wasm_bindgen] +pub fn add_session_to_account( + config: SendTransactionConfig, + account_address: String, + session_payload: SessionPayload, + session_validator_address: String, + eoa_validator_address: String, + eoa_private_key: String, +) -> js_sys::Promise +``` + +This approach: + +- Works with existing core functions +- Follows same pattern as `add_passkey_to_account` +- Allows flexible session management +- Doesn't require core deploy changes + +Implementation: + +- Function implemented in `zksync-sso-erc4337-ffi-web/src/lib.rs` as + `add_session_to_account` +- Converts WASM `SessionPayload` to core `SessionSpec` and calls core helpers to + install the module and create a session + +### 1.6 Session Transaction Functions ▶ Deferred + +**Status**: DEFERRED to Phase 4 (Session Transaction Sending) + +We will add prepare/submit helpers for session-signed transactions alongside the +broader "Session Transaction Sending" work to keep responsibilities grouped. +Add functions to prepare and send session transactions: + +```rust +#[wasm_bindgen] +pub fn prepare_session_user_operation(...) -> js_sys::Promise + +#[wasm_bindgen] +pub fn submit_session_user_operation(...) -> js_sys::Promise +``` + +### 1.7 Tests ✅ + +**Status**: COMPLETED + +Added and validated tests in `zksync-sso-erc4337-ffi-web/src/lib.rs`: + +- wasm_transport creation +- Passkey two-step flow (prepare + submit) +- Session add-and-verify flow (install SessionKeyValidator and create session) +- Deploy-with-session flow (session installed at account creation) + +Result: 4/4 tests passing locally with Anvil + Alto bundler. All session install +flows are covered. + +## Phase 2: TypeScript/Web SDK Updates + +**Status**: COMPLETED (Nov 6, 2025) + +Core API wiring, TypeScript interfaces, session helpers, unit tests, and e2e +tests for session installation flows all completed. + +Actionable checklist: + +- [x] Export `SessionPayload` and `TransferPayload` from the WASM bundle in + `bundler.ts` +- [x] Define and export TS interfaces (`SessionConfig`, `UsageLimit`, + `TransferSpec`) +- [x] Wire `add_session_to_account` and deploy-with-session into the Web SDK + surface with friendly wrappers +- [x] Add browser/e2e tests for both session install flows (deploy-with-session + and post-deploy) — added in `examples/demo-app/tests/web-sdk-test.spec.ts` +- [x] Update docs and `contracts.json` handling to surface `sessionValidator` + address — documented in `examples/demo-app/README.md` +- [ ] Add browser/e2e tests for session transaction sending (prepare/submit) — + deferred to Phase 4 + +### 2.1 Export Types from bundler.ts ✅ + +Exported WASM types and helper functions: + +```typescript +export const { + // ... existing exports ... + SessionPayload, + TransferPayload, + add_session_to_account, +} = wasm; + +// Re-export session helpers +export { toSessionPayload, deployAccountWithSession, addSessionToAccount } from './session'; +``` + +### 2.2 Create TypeScript Types ✅ + +**File**: `packages/sdk-platforms/web/src/types.ts` + +```typescript +export interface SessionConfig { + signer: `0x${string}`; + expiresAt: number | bigint; + feeLimit: { + limitType: 'unlimited' | 'lifetime' | 'allowance'; + limit: bigint; + period?: number; + }; + transfers: Array<{ + to: `0x${string}`; + valueLimit: bigint; + limitType?: 'unlimited' | 'lifetime' | 'allowance'; + period?: number; + }; +} +``` + +### 2.3 Session Helper Functions ✅ + +**File**: `packages/sdk-platforms/web/src/session.ts` + +Implemented helper functions for ergonomic session usage: + +- `toSessionPayload(spec: SessionConfig)` - Converts TS config to WASM payload +- `deployAccountWithSession(...)` - Deploy account with optional session +- `addSessionToAccount(...)` - Install session on existing account + +### 2.4 Unit Tests ✅ + +**File**: `packages/sdk-platforms/web/src/session.test.ts` + +Unit tests covering session payload conversion and wrapper invocations. +Result: All tests passing (8 tests total in web SDK). + +### 2.5 E2E Tests ✅ + +**File**: `examples/demo-app/tests/web-sdk-test.spec.ts` + +Browser e2e tests covering: + +- Deploy with session at deploy time +- Install session post-deploy + +Result: All tests passing (4/4 in ERC-4337 test suite). + +### 2.6 Documentation ✅ + +**File**: `examples/demo-app/README.md` + +Documented `contracts.json` configuration including `sessionValidator` field +required for session flows. + +## Phase 3: Vue Component Updates + +Status: COMPLETED (Nov 6, 2025). Session UI component extracted and integrated. + +### 3.1 Add Session State ✅ + +Session state management added to demo page with reactive refs for configuration. + +### 3.2 Create SessionConfig Component ✅ + +**File**: `examples/demo-app/components/SessionConfig.vue` + +Reusable Vue component for configuring session parameters: + +- Enable/disable toggle +- Session signer address input +- Expiration period (days) +- Fee limit configuration +- Transfer target and value limits +- Follows same pattern as PasskeyConfig and WalletConfig components + +Integrated into `examples/demo-app/pages/web-sdk-test.vue` using v-model. + +### 3.3 Update deployAccount Function ✅ + +Integrated session payload creation and routed deploy through +`deployAccountWithSession` helper. Supports both deploy-with-session and +standalone deployment flows. + +## Phase 4: Session Transaction Sending + +**Status**: PENDING + +This phase will add the ability to send transactions signed by session keys. + +### 4.1 Add Session Transaction Functions to FFI + +Add Rust/WASM functions for session-signed transactions: + +```rust +#[wasm_bindgen] +pub fn prepare_session_user_operation(...) -> js_sys::Promise + +#[wasm_bindgen] +pub fn submit_session_user_operation(...) -> js_sys::Promise +``` + +### 4.2 Create TypeScript Wrappers + +Add convenience wrappers in `packages/sdk-platforms/web/src/session.ts`: + +- `prepareSessionTransaction(...)` +- `submitSessionTransaction(...)` +- `sendTransactionWithSession(...)` - High-level wrapper combining both + +### 4.3 Update TransactionSender Component + +**File**: `examples/demo-app/components/TransactionSender.vue` + +Add session signing mode: + +- Radio button option for "Session Key" alongside EOA and Passkey +- Session key private key input (or load from config) +- UI to show session limits and remaining allowances + +### 4.4 Add E2E Tests + +**File**: `examples/demo-app/tests/web-sdk-test.spec.ts` + +Add test case: + +- Deploy account with session +- Fund account +- Send transaction using session key signature +- Verify transaction succeeds and limits are enforced + +## Phase 5: Testing & Validation + +**Status**: PARTIAL - Session installation flows validated; transaction sending +pending Phase 4 + +### Test Cases + +1. ✅ Deploy without session - existing flow (e2e passing) +2. ✅ Deploy with session - new flow (e2e passing) +3. ✅ Install session post-deploy (e2e passing) +4. ⏳ Send transaction with session - pending Phase 4 +5. ⏳ Session expiration enforcement - pending Phase 4 +6. ⏳ Fee limit enforcement - pending Phase 4 +7. ⏳ Value limit enforcement - pending Phase 4 +8. ⏳ Multiple transfers within limits - pending Phase 4 +9. ⏳ Session with passkey + EOA - pending Phase 4 + +### contracts.json Update ✅ + +Documented in `examples/demo-app/README.md`. The `sessionValidator` field is now +included in deployment scripts and properly loaded by the demo app. + +## Implementation Summary + +### Completed Work (Phases 1-3) + +#### Phase 1: Rust/WASM FFI Layer + +- ✅ Session payload types (SessionPayload, TransferPayload) +- ✅ DeployAccountConfig extended with session_validator_address +- ✅ deploy_account supports optional session payload +- ✅ add_session_to_account for post-deployment installation +- ✅ FFI tests covering both installation flows (4/4 passing) + +#### Phase 2: TypeScript/Web SDK + +- ✅ Exported WASM session types to bundler +- ✅ TypeScript interfaces (SessionConfig, UsageLimit, TransferSpec, LimitType) +- ✅ Helper functions (toSessionPayload, deployAccountWithSession, + addSessionToAccount) +- ✅ Unit tests for session conversion and wrappers (8/8 passing) +- ✅ E2E tests for both installation flows (4/4 passing) +- ✅ Documentation of contracts.json configuration + +#### Phase 3: Vue Components + +- ✅ SessionConfig.vue component with v-model support +- ✅ Session state management in demo page +- ✅ Integration with deploy flow +- ✅ Post-deploy session installation UI + +### Remaining Work (Phases 4-5) + +#### Phase 4: Session Transaction Sending + +- ⏳ FFI functions for session-signed transactions +- ⏳ TypeScript wrappers for session transaction flow +- ⏳ TransactionSender component updates for session key option +- ⏳ E2E tests for session-signed transactions + +#### Phase 5: Additional Testing + +- ⏳ Session limit enforcement tests +- ⏳ Session expiration tests +- ⏳ Multi-validator scenarios (session + passkey + EOA) + +### Files Modified/Created + +**Rust/WASM**: + +- `packages/sdk-platforms/rust/zksync-sso-erc4337-ffi-web/src/lib.rs` + +**TypeScript/Web SDK**: + +- `packages/sdk-platforms/web/src/bundler.ts` +- `packages/sdk-platforms/web/src/types.ts` +- `packages/sdk-platforms/web/src/session.ts` (new) +- `packages/sdk-platforms/web/src/session.test.ts` (new) +- `packages/sdk-platforms/web/src/index.ts` + +**Demo App**: + +- `examples/demo-app/components/SessionConfig.vue` (new) +- `examples/demo-app/pages/web-sdk-test.vue` +- `examples/demo-app/tests/web-sdk-test.spec.ts` +- `examples/demo-app/README.md` + +**Documentation**: + +- `packages/sdk-platforms/web/SESSION_WEB_SDK_PLAN.md` (this file) + +## Implementation Order + +1. ✅ **Phase 1** - Rust FFI layer *(COMPLETED)* + - Added WASM types (SessionPayload, TransferPayload) + - Updated deploy_account with optional session support + - Added conversion logic with robust error handling + - Comprehensive tests (4/4 passing) + +2. ✅ **Phase 2** - TypeScript exports *(COMPLETED)* + - Exported WASM types to bundler + - Defined TS interfaces for ergonomic usage + - Created helper wrappers (toSessionPayload, deployAccountWithSession, + addSessionToAccount) + - Unit and e2e tests passing + +3. ✅ **Phase 3** - Vue UI *(COMPLETED)* + - Created reusable SessionConfig component + - Updated deployment flow to support sessions + - Added post-deploy session installation UI + +4. ⏳ **Phase 4** - Transaction sending *(NEXT)* + - Add session signing functions to FFI + - TypeScript wrappers for session transactions + - Update TransactionSender component for session key option + - E2E tests for session-signed transactions + +5. ⏳ **Phase 5** - Integration testing *(PENDING)* + - Session limit enforcement tests + - Session expiration tests + - Multi-validator scenario tests + +## Key Design Decisions + +1. **Stateless Design**: All session data passed explicitly, no global state +2. **Limit Types**: Support unlimited, lifetime, and allowance limits +3. **Security**: Session keys are separate from EOA/passkey signers +4. **Compatibility**: Works alongside EOA and passkey authentication +5. **Flexibility**: Multiple transfers per session, each with own limits +6. **Dual Installation Paths**: Support both deploy-with-session and + post-deployment installation +7. **Component Reusability**: SessionConfig component follows established + patterns (PasskeyConfig, WalletConfig) + +## References + +- Core session types: + `packages/sdk-platforms/rust/zksync-sso-erc4337/crates/zksync-sso-erc4337-core/src/erc4337/account/modular_smart_account/session/` +- Existing Rust SDK tests: + `packages/sdk-platforms/rust/zksync-sso/crates/sdk/src/client/session/` +- Swift integration: + `packages/sdk-platforms/swift/ZKsyncSSOIntegration/Sources/ZKsyncSSOIntegration/Actions/DeployAccount.swift` +- Web SDK session helpers: + `packages/sdk-platforms/web/src/session.ts` +- SessionConfig component: + `examples/demo-app/components/SessionConfig.vue` diff --git a/packages/sdk-platforms/web/src/bundler.ts b/packages/sdk-platforms/web/src/bundler.ts index a1ecb4378..a9801702f 100644 --- a/packages/sdk-platforms/web/src/bundler.ts +++ b/packages/sdk-platforms/web/src/bundler.ts @@ -1,30 +1,37 @@ // Bundler-specific entry point for web applications +// Bundler target auto-initializes the wasm module (see generated JS invoking __wbindgen_start()) +// We import only the namespace; no explicit init call required for bundler target. import * as wasm from "../pkg-bundler/zksync_sso_erc4337_web_ffi"; +export * from "./session"; export * from "./types"; export * from "./webauthn"; export * from "./webauthn-helpers"; // Re-export core WASM functions for passkey flow -export const { - // ===== CORE PASSKEY FUNCTIONS (PRIMARY API) ===== - // These are the essential functions for implementing passkey-based smart accounts - deploy_account, // Deploy a new smart account with passkey - add_passkey_to_account, // Add additional passkey to existing account - prepare_passkey_user_operation, // Prepare transaction for passkey signing - send_transaction_eoa, - submit_passkey_user_operation, // Submit signed transaction to bundler - compute_account_id, // Generate unique account ID from passkey +// Explicitly export WASM functions and types +export const deploy_account = wasm.deploy_account; +export const add_passkey_to_account = wasm.add_passkey_to_account; +export const add_session_to_account = wasm.add_session_to_account; +export const prepare_passkey_user_operation = wasm.prepare_passkey_user_operation; +export const send_transaction_eoa = wasm.send_transaction_eoa; +export const send_transaction_session = wasm.send_transaction_session; +export const submit_passkey_user_operation = wasm.submit_passkey_user_operation; +export const compute_account_id = wasm.compute_account_id; - // ===== CONFIGURATION TYPES ===== - // TypeScript interfaces for configuration objects - PasskeyPayload, // Passkey credential data - DeployAccountConfig, // Account deployment configuration - SendTransactionConfig, // Transaction sending configuration -} = wasm; +export const PasskeyPayload = wasm.PasskeyPayload; +export const DeployAccountConfig = wasm.DeployAccountConfig; +export const SendTransactionConfig = wasm.SendTransactionConfig; -// Initialize WASM module -export const init = wasm.init; +// ===== SESSION TYPES (PHASE 2) ===== +export const SessionPayload = wasm.SessionPayload; +export const TransferPayload = wasm.TransferPayload; -// Auto-initialize for bundler environments -wasm.init(); +// Initialize WASM module (async for web target) +// Provide a no-op async init for API symmetry with node target. +export async function init(): Promise { + // wasm already started; nothing to do. +} + +// Optional eager init (no-op but keeps previous semantics) +init(); diff --git a/packages/sdk-platforms/web/src/index.ts b/packages/sdk-platforms/web/src/index.ts index e06df2d33..b4033267a 100644 --- a/packages/sdk-platforms/web/src/index.ts +++ b/packages/sdk-platforms/web/src/index.ts @@ -1,5 +1,6 @@ // Main entry point - detects environment and uses appropriate WASM target export * from "./client"; +export * from "./session"; export * from "./types"; // Platform-specific exports for advanced usage diff --git a/packages/sdk-platforms/web/src/session.test.ts b/packages/sdk-platforms/web/src/session.test.ts new file mode 100644 index 000000000..96291c92f --- /dev/null +++ b/packages/sdk-platforms/web/src/session.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("./bundler", () => { + class TransferPayloadMock { + target: string; + value_limit_value: string; + value_limit_type: number; + value_limit_period: string; + constructor(target: string, value: string, t: number, p: string) { + this.target = target; + this.value_limit_value = value; + this.value_limit_type = t; + this.value_limit_period = p; + } + } + class SessionPayloadMock { + signer: string; + expires_at: string; + fee_limit_value: string; + fee_limit_type: number; + fee_limit_period: string; + transfers: unknown[]; + constructor(signer: string, expires: string, val: string, t: number, p: string, txs: unknown[]) { + this.signer = signer; + this.expires_at = expires; + this.fee_limit_value = val; + this.fee_limit_type = t; + this.fee_limit_period = p; + this.transfers = txs; + } + } + const add_session_to_account = vi.fn(async () => ({ ok: true })); + const deploy_account = vi.fn(async () => ({ address: "0xabc" })); + return { + SessionPayload: SessionPayloadMock, + TransferPayload: TransferPayloadMock, + add_session_to_account, + deploy_account, + }; +}); + +import { addSessionToAccount, deployAccountWithSession, toSessionPayload } from "./session"; + +const baseSession = { + signer: "0x1234567890123456789012345678901234567890" as const, + expiresAt: 1234567890, + feeLimit: { limitType: "lifetime" as const, limit: 1000000000000000000n }, + transfers: [ + { to: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" as const, valueLimit: 1000000000000000n }, + ], +}; + +describe("session helpers", () => { + it("converts SessionConfig to SessionPayload", () => { + const payload = toSessionPayload(baseSession); + expect(payload.signer).toBe(baseSession.signer); + expect(payload.expires_at).toBe(String(baseSession.expiresAt)); + expect(payload.fee_limit_type).toBe(1); // lifetime + expect(payload.fee_limit_value).toBe("1000000000000000000"); + expect(Array.isArray(payload.transfers)).toBe(true); + expect(payload.transfers[0].target).toBe(baseSession.transfers[0].to); + }); + + it("calls deploy_account with session when provided", async () => { + const result = await deployAccountWithSession({ + userId: "user-1", + eoaSigners: null, + passkeyPayload: null, + session: baseSession, + deployConfig: {} as unknown as object, + }); + expect(result).toBeDefined(); + }); + + it("calls add_session_to_account with converted payload", async () => { + const res = await addSessionToAccount({ + txConfig: {} as unknown as object, + accountAddress: "0xabc", + session: baseSession, + sessionValidatorAddress: "0xsv", + eoaValidatorAddress: "0xev", + eoaPrivateKey: "0xpk", + }); + expect(res).toBeDefined(); + }); +}); diff --git a/packages/sdk-platforms/web/src/session.ts b/packages/sdk-platforms/web/src/session.ts new file mode 100644 index 000000000..8c677f8ec --- /dev/null +++ b/packages/sdk-platforms/web/src/session.ts @@ -0,0 +1,120 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + add_session_to_account as wasm_add_session_to_account, + deploy_account as wasm_deploy_account, + send_transaction_session as wasm_send_transaction_session, + SessionPayload as WasmSessionPayload, + TransferPayload as WasmTransferPayload, +} from "./bundler"; +import type { LimitType, SessionConfig, TransferSpec } from "./types"; + +function limitTypeToCode(t: LimitType | undefined): number { + switch (t) { + case "unlimited": + return 0; + case "allowance": + return 2; + case "lifetime": + default: + return 1; + } +} + +function toDecString(v: bigint | number): string { + if (typeof v === "bigint") return v.toString(10); + if (Number.isInteger(v)) return String(v); + // Fallback: floor decimals (caller should pass wei as bigint ideally) + return String(Math.trunc(v)); +} + +function buildTransfers(specs: TransferSpec[]): any[] { + return specs.map((t) => + new (WasmTransferPayload as any)( + t.to, + toDecString(t.valueLimit), + limitTypeToCode(t.limitType), + String(t.period ?? 0), + ), + ); +} + +export function toSessionPayload(spec: SessionConfig): any { + const transfers = buildTransfers(spec.transfers); + const feeLimitType = limitTypeToCode(spec.feeLimit?.limitType); + const feeLimitValue = toDecString(spec.feeLimit.limit); + const feeLimitPeriod = String(spec.feeLimit.period ?? 0); + const expiresAtStr = typeof spec.expiresAt === "bigint" + ? spec.expiresAt.toString(10) + : String(spec.expiresAt); + + return new (WasmSessionPayload as any)( + spec.signer, + expiresAtStr, + feeLimitValue, + feeLimitType, + feeLimitPeriod, + transfers, + ); +} + +// Convenience wrapper: deploy account with optional session +export async function deployAccountWithSession(args: { + userId: string; + eoaSigners?: string[] | null; + passkeyPayload?: any | null; + session?: SessionConfig | null; + deployConfig: any; // wasm DeployAccountConfig instance +}): Promise { + const sessionPayload = args.session ? toSessionPayload(args.session) : null; + return wasm_deploy_account( + args.userId, + args.eoaSigners ?? null, + args.passkeyPayload ?? null, + sessionPayload, + args.deployConfig, + ); +} + +// Convenience wrapper: add session to existing account +export async function addSessionToAccount(args: { + txConfig: any; // wasm SendTransactionConfig + accountAddress: string; + session: SessionConfig; + sessionValidatorAddress: string; + eoaValidatorAddress: string; + eoaPrivateKey: string; +}): Promise { + const sessionPayload = toSessionPayload(args.session); + return wasm_add_session_to_account( + args.txConfig, + args.accountAddress, + sessionPayload, + args.sessionValidatorAddress, + args.eoaValidatorAddress, + args.eoaPrivateKey, + ); +} + +// Convenience wrapper: send transaction using session key +export async function sendTransactionWithSession(args: { + txConfig: any; // wasm SendTransactionConfig + sessionValidatorAddress: string; + accountAddress: string; + to: string; + value: string; + data?: string | null; + sessionPrivateKey: string; + session: SessionConfig; +}): Promise { + const sessionPayload = toSessionPayload(args.session); + return wasm_send_transaction_session( + args.txConfig, + args.sessionValidatorAddress, + args.accountAddress, + args.to, + args.value, + args.data ?? null, + args.sessionPrivateKey, + sessionPayload, + ); +} diff --git a/packages/sdk-platforms/web/src/types.ts b/packages/sdk-platforms/web/src/types.ts index 7d96c05d0..b538ba7c8 100644 --- a/packages/sdk-platforms/web/src/types.ts +++ b/packages/sdk-platforms/web/src/types.ts @@ -61,3 +61,42 @@ export interface InitOptions { /** Enable debug logging (optional) */ debug?: boolean; } + +// ===== Session Types (Phase 2) ===== + +/** Types of usage limits for fees and transfers */ +export type LimitType = "unlimited" | "lifetime" | "allowance"; + +/** Usage limit definition */ +export interface UsageLimit { + /** Limit type */ + limitType: LimitType; + /** Total limit amount (wei) */ + limit: bigint; + /** Period in seconds for allowance type (optional) */ + period?: number; +} + +/** Transfer permission within a session */ +export interface TransferSpec { + /** Target address */ + to: `0x${string}`; + /** Max ETH per transfer (wei) */ + valueLimit: bigint; + /** Optional override of limit type for this transfer */ + limitType?: LimitType; + /** Optional period for allowance type */ + period?: number; +} + +/** Session configuration passed from web app */ +export interface SessionConfig { + /** Session key address */ + signer: `0x${string}`; + /** Expiration timestamp (unix seconds) */ + expiresAt: number | bigint; + /** Fee usage limit */ + feeLimit: UsageLimit; + /** Allowed transfers */ + transfers: TransferSpec[]; +}