Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions examples/demo-app/components/SessionCreator.vue
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@

<script setup lang="ts">
import { ref, computed } from "vue";
import { createPublicClient, http, parseEther, type Chain, type Address } from "viem";
import { createPublicClient, http, parseEther, type Address, type Chain, encodePacked, keccak256, pad } from "viem";
import { createBundlerClient } from "viem/account-abstraction";
import { createSession, toEcdsaSmartAccount, LimitType } from "zksync-sso-4337/client";
import { privateKeyToAccount } from "viem/accounts";
import { createSession, toEcdsaSmartAccount, LimitType, getSessionHash } from "zksync-sso-4337/client";

interface SessionConfig {
enabled: boolean;
Expand Down Expand Up @@ -234,18 +235,36 @@ async function createSessionOnChain() {

// eslint-disable-next-line no-console
console.log("Session spec created:", sessionSpec);

// Generate proof
const sessionHash = getSessionHash(sessionSpec);

// Sign over (sessionHash, account) to bind session to account
// Matches Rust: keccak256([session_hash, account.address().into_word()].concat())
const digest = keccak256(encodePacked(
["bytes32", "bytes32"],
[sessionHash, pad(props.accountAddress as Address)],
));

const sessionSignerAccount = privateKeyToAccount(props.sessionConfig.sessionPrivateKey as `0x${string}`);
const proof = await sessionSignerAccount.sign({
hash: digest,
});

// eslint-disable-next-line no-console
console.log("Calling createSession with:", {
bundlerClient: !!bundlerClient,
sessionSpec: !!sessionSpec,
sessionValidator: props.sessionConfig.validatorAddress,
proof,
});

// Create the session on-chain
let sessionResult;
try {
sessionResult = await createSession(bundlerClient, {
sessionSpec,
proof,
contracts: {
sessionValidator: props.sessionConfig.validatorAddress as Address,
},
Expand All @@ -268,6 +287,13 @@ async function createSessionOnChain() {
// eslint-disable-next-line no-console
console.log("Session created successfully! UserOp hash:", userOpHash);

// Wait for the UserOp to be mined
// eslint-disable-next-line no-console
console.log("Waiting for session creation UserOp to be mined...");
await bundlerClient.waitForUserOperationReceipt({ hash: userOpHash });
// eslint-disable-next-line no-console
console.log("Session creation UserOp mined!");

result.value = { userOpHash };
sessionCreated.value = true;
emit("sessionCreated");
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk-4337/src/abi/SessionKeyValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ export const SessionKeyValidatorAbi = [{
}],
}],
}],
}, {
name: "proof",
type: "bytes",
internalType: "bytes",
}],
outputs: [],
stateMutability: "nonpayable",
Expand Down
10 changes: 8 additions & 2 deletions packages/sdk-4337/src/client/actions/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export type CreateSessionParams = {
*/
sessionSpec: SessionSpec;

/**
* Proof of session key ownership (signature of session hash by session key)
*/
proof: Hex;

/**
* Contract addresses
*/
Expand Down Expand Up @@ -82,6 +87,7 @@ export type CreateSessionReturnType = {
* ],
* transferPolicies: [],
* },
* proof: "0x...", // Signature of session hash by session key
* contracts: {
* sessionValidator: "0x...", // SessionKeyValidator module address
* },
Expand All @@ -96,15 +102,15 @@ export async function createSession<
client: Client<TTransport, TChain, TAccount>,
params: CreateSessionParams,
): Promise<CreateSessionReturnType> {
const { sessionSpec, contracts } = params;
const { sessionSpec, proof, contracts } = params;

if (!client.account) {
throw new Error("Client must have an account");
}

// Convert SessionSpec into JSON string expected by wasm helper
const sessionSpecJSON = sessionSpecToJSON(sessionSpec);
const callData = encode_create_session_call_data(sessionSpecJSON) as Hex;
const callData = encode_create_session_call_data(sessionSpecJSON, proof) as Hex;
// Send the UserOperation to create the session using the bundler client method.
// Note: We explicitly pass the account for broader viem compatibility.
const bundler = client as unknown as {
Expand Down
6 changes: 3 additions & 3 deletions packages/sdk-4337/src/client/passkey/client-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export type PasskeyClientActions<TChain extends Chain = Chain, TAccount extends
/**
* Create a session on-chain using the provided specification
*/
createSession: (params: { sessionSpec: SessionSpec; contracts: { sessionValidator: Address } }) => Promise<Hash>;
createSession: (params: { sessionSpec: SessionSpec; proof: Hex; contracts: { sessionValidator: Address } }) => Promise<Hash>;
};

/**
Expand Down Expand Up @@ -93,11 +93,11 @@ export function passkeyClientActions<
},

// Create a session on-chain using the provided specification
createSession: async (params: { sessionSpec: SessionSpec; contracts: { sessionValidator: Address } }) => {
createSession: async (params: { sessionSpec: SessionSpec; proof: Hex; contracts: { sessionValidator: Address } }) => {
// Build smart account instance (lazy, not cached here; acceptable overhead for now)
const smartAccount = await toPasskeySmartAccount(config.passkeyAccount);
const sessionSpecJSON = sessionSpecToJSON(params.sessionSpec);
const callData = encode_create_session_call_data(sessionSpecJSON) as unknown as `0x${string}`;
const callData = encode_create_session_call_data(sessionSpecJSON, params.proof) as unknown as `0x${string}`;
const userOpHash = await config.bundler.sendUserOperation({
account: smartAccount,
calls: [
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk-4337/src/client/session/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe("Session Utils", () => {
const parsed = JSON.parse(json);
expect(parsed.signer).toBe(signer);
expect(typeof parsed.expiresAt).toBe("string"); // bigints as strings
expect(parsed.feeLimit.limitType).toBe(0); // LimitType.Unlimited = 0
expect(parsed.feeLimit.limitType).toBe("Unlimited"); // LimitType.Unlimited
});

it("should handle complex session spec with policies", () => {
Expand Down Expand Up @@ -148,7 +148,7 @@ describe("Session Utils", () => {

expect(parsed.callPolicies).toHaveLength(1);
expect(parsed.transferPolicies).toHaveLength(1);
expect(parsed.feeLimit.limitType).toBe(1); // LimitType.Lifetime = 1
expect(parsed.feeLimit.limitType).toBe("Lifetime"); // LimitType.Lifetime
expect(parsed.feeLimit.limit).toBe("1000000000000000000");
});
});
Expand Down
8 changes: 4 additions & 4 deletions packages/sdk-4337/src/client/session/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import type { Address, Hex } from "viem";

/**
* Limit types for usage tracking
* Uses string values to match Rust serialization format
* Uses numeric values to match contract enum
*/
export enum LimitType {
Unlimited = "Unlimited",
Lifetime = "Lifetime",
Allowance = "Allowance",
Unlimited = 0,
Lifetime = 1,
Allowance = 2,
}

/**
Expand Down
42 changes: 35 additions & 7 deletions packages/sdk-4337/src/client/session/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Address, Hex } from "viem";
import { type Address, encodeAbiParameters, type Hex, keccak256 } from "viem";

import type { SessionSpec, UsageLimit } from "./types.js";
import { SessionKeyValidatorAbi } from "../../abi/SessionKeyValidator.js";
import { LimitType, type SessionSpec, type UsageLimit } from "./types.js";

/**
* Utility type that converts all bigint values to strings recursively
Expand All @@ -23,11 +24,17 @@ export type SessionSpecJSON = ConvertBigIntToString<SessionSpec>;
* All bigint values are converted to strings for safe serialization.
*/
export function sessionSpecToJSON(spec: SessionSpec): string {
const usageLimitToJSON = (limit: UsageLimit) => ({
limitType: limit.limitType,
limit: limit.limit.toString(),
period: limit.period.toString(),
});
const usageLimitToJSON = (limit: UsageLimit) => {
let limitType = "Unlimited";
if (limit.limitType === LimitType.Lifetime) limitType = "Lifetime";
else if (limit.limitType === LimitType.Allowance) limitType = "Allowance";

return {
limitType,
limit: limit.limit.toString(),
period: limit.period.toString(),
};
};

return JSON.stringify({
signer: spec.signer,
Expand Down Expand Up @@ -130,3 +137,24 @@ export function isSessionExpired(
export function getSessionExpiryDate(spec: SessionSpec): Date {
return new Date(Number(spec.expiresAt) * 1000);
}

/**
* Computes the hash of a session specification.
* This hash is signed by the session key to prove ownership.
*/
export function getSessionHash(spec: SessionSpec): Hex {
const createSessionFunction = SessionKeyValidatorAbi.find(
(x) => x.type === "function" && x.name === "createSession",
);
if (!createSessionFunction) throw new Error("createSession function not found in SessionKeyValidator ABI");

const sessionSpecParam = createSessionFunction.inputs.find((x) => x.name === "sessionSpec");
if (!sessionSpecParam) throw new Error("sessionSpec parameter not found in createSession function inputs");

const encoded = encodeAbiParameters(
[sessionSpecParam],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[spec as any],
);
return keccak256(encoded);
}
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ mod tests {
calls::{Execution, encode_calls},
module::{
Module,
add::{AddModuleParams, AddModulePayload, add_module},
installed::{
IsModuleInstalledParams, is_module_installed,
},
Expand Down Expand Up @@ -246,8 +247,9 @@ mod tests {
},
};
use alloy::{
primitives::{U256, Uint, address},
signers::local::PrivateKeySigner,
primitives::{U256, Uint, address, keccak256},
signers::{SignerSync, local::PrivateKeySigner},
sol_types::SolValue,
};
use std::{future::Future, pin::Pin, str::FromStr, sync::Arc};

Expand Down Expand Up @@ -458,30 +460,27 @@ mod tests {
PrivateKeySigner::from_str(session_key_hex)?.address();
println!("Session signer address: {}", session_signer_address);

// Deploy account WITH session validator pre-installed
// Deploy account WITHOUT session validator pre-installed
let signers =
vec![address!("0xa0Ee7A142d267C1f36714E4a8F75612F20a79720")];
let eoa_signers = EOASigners {
addresses: signers,
validator_address: eoa_validator_address,
};
let session_config = SessionValidatorConfig {
validator_address: session_validator_address,
};

let address = deploy_account(DeployAccountParams {
factory_address,
eoa_signers: Some(eoa_signers),
webauthn_signer: None,
session_validator: Some(session_config),
session_validator: None,
id: None,
provider: provider.clone(),
})
.await?;

println!("✓ Account deployed with session validator pre-installed");
println!("✓ Account deployed (without session validator)");

// Verify both modules are installed
// Verify EOA module is installed
let is_eoa_installed = is_module_installed(IsModuleInstalledParams {
module: Module::eoa_validator(eoa_validator_address),
account: address,
Expand All @@ -490,6 +489,31 @@ mod tests {
.await?;
eyre::ensure!(is_eoa_installed, "EOA validator not installed");

// Fund the account
fund_account_with_default_amount(address, provider.clone()).await?;
println!("✓ Account funded");

// Create EOA signer
let eoa_signer = create_eoa_signer(
signer_private_key.clone(),
eoa_validator_address,
)?;

// Install Session Validator Module
add_module(AddModuleParams {
account_address: address,
module: AddModulePayload::session_key(session_validator_address),
entry_point_address,
paymaster: None,
provider: provider.clone(),
bundler_client: bundler_client.clone(),
signer: eoa_signer.clone(),
})
.await?;

println!("✓ Session validator installed");

// Verify session module is installed
let is_session_installed =
is_module_installed(IsModuleInstalledParams {
module: Module::session_key_validator(
Expand All @@ -503,16 +527,6 @@ mod tests {

println!("✓ Both EOA and Session validators verified as installed");

// Fund the account
fund_account_with_default_amount(address, provider.clone()).await?;
println!("✓ Account funded");

// Create a session using EOA signer
let eoa_signer = create_eoa_signer(
signer_private_key.clone(),
eoa_validator_address,
)?;

let expires_at = Uint::from(2088558400u64); // Year 2036
let target = address!("0xa0Ee7A142d267C1f36714E4a8F75612F20a79720");

Expand All @@ -536,6 +550,16 @@ mod tests {
}],
};

// Calculate proof
let session_lib_spec: crate::erc4337::account::modular_smart_account::session::contract::SessionLib::SessionSpec = session_spec.clone().into();
let session_hash = keccak256(session_lib_spec.abi_encode());
let digest = keccak256((session_hash, address).abi_encode());

let session_signer_instance =
PrivateKeySigner::from_str(session_key_hex)?;
let proof =
session_signer_instance.sign_hash_sync(&digest)?.as_bytes().into();

create_session(CreateSessionParams {
account_address: address,
spec: session_spec.clone(),
Expand All @@ -545,6 +569,7 @@ mod tests {
bundler_client: bundler_client.clone(),
provider: provider.clone(),
signer: eoa_signer,
proof,
})
.await?;

Expand Down
Loading
Loading