How to use session keys using Smart Sessions
The Smart Sessions Module allows developers to create session keys with scoped permissions and access rights on a users account. This allows the user to determine exactly what an app is allowed to do on its' behalf and enforce this onchain. This guide will show you how to set up and use Smart Sessions with a Safe smart account using permissionless.js SDK.
We will first set up the smart account, install the Smart Sessions Module, create a session and then use this new session to execute a UserOperation.
Install the packages
First, install the required packages:
npm i viem @rhinestone/module-sdk permissionless
Set up the smart account
Next, we will first create an account and install a validator on it. We use the smartAccountClient
to install the module, as created in the permissionless.js guide. To follow this step, head over to the aforementioned guide or use another account sdk.
Install the Smart Sessions Module
Next, we will install the Smart Sessions Module on the users account. Note that while installing the module, we can also pass an initial set of sessions to be activated.
In this step we will activate such a session, but we will then activate another session using the multichain enable flow and use this last section. This is not required but only intended to show the two different ways of activating a new session.
In this demo session, we will use the ownable validator as the session validator and give it an owner address. We also use the sudo policy to allow any actions by the session key owner on the selected target.
import {
getSmartSessionsValidator,
OWNABLE_VALIDATOR_ADDRESS,
getSudoPolicy,
} from "@rhinestone/module-sdk";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import { encodeAbiParameters, toHex, toBytes, Address, Hex } from "viem";
const smartSessions = getSmartSessionsValidator({
sessions: [
{
sessionValidator: OWNABLE_VALIDATOR_ADDRESS,
sessionValidatorInitData: encodeAbiParameters(
[
{
type: "uint256",
},
{
type: "address[]",
},
],
[BigInt(1), [privateKeyToAccount(generatePrivateKey()).address]]
),
salt: toHex(toBytes("2", { size: 32 })),
userOpPolicies: [],
erc7739Policies: {
allowedERC7739Content: [],
erc1271Policies: [],
},
actions: [
{
actionTarget: "0xa564cB165815937967a7d018B7F34B907B52fcFd" as Address, // an address as the target of the session execution
actionTargetSelector: "0x00000000" as Hex, // function selector to be used in the execution, in this case no function selector is used
actionPolicies: [
{
policy: getSudoPolicy().address,
initData: getSudoPolicy().initData,
},
],
},
],
},
],
});
const opHash = await smartAccountClient.installModule({
type: smartSessions.type,
address: smartSessions.module,
context: smartSessions.data,
});
Create the session to enable
In the section above, we create a session inline and installed it on the smart sessions. We could now use this session to execute UserOperations.
However, to demonstrate the second way of creating a session, we will now create and activate a new session and then use it to execute a UserOperation. First we will create the session details.
iimport { Session, OWNABLE_VALIDATOR_ADDRESS, getSudoPolicy } from "@rhinestone/module-sdk";
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
import {encodeAbiParameters, toHex, toBytes, Address, Hex} from "viem";
const sessionOwner = privateKeyToAccount(generatePrivateKey());
const session: Session = {
sessionValidator:
OWNABLE_VALIDATOR_ADDRESS,
sessionValidatorInitData: encodeAbiParameters(
[
{
type: 'uint256',
},
{
type: 'address[]',
},
],
[
BigInt(1),
[sessionOwner.address],
],
),
salt: toHex(toBytes('41414141', { size: 32 })),
userOpPolicies: [],
erc7739Policies: {
allowedERC7739Content: [],
erc1271Policies: [],
},
actions: [
{
actionTarget:
'0xa564cB165815937967a7d018B7F34B907B52fcFd' as Address, // an address as the target of the session execution
actionTargetSelector: '0x00000000' as Hex, // function selector to be used in the execution, in this case no function selector is used
actionPolicies: [
{
policy: getSudoPolicy().address,
initData: getSudoPolicy().initData,
},
],
},
],
},
Get the session data
Now that we have created the session object, we will get some data that we will need for enabling the session.
First we get the permission id (the identifier of the session) so that we can tell the smart sessions module which session we want to use. Then, we get the sessionNonce that we will sign over for replay protection.
import {
getClient,
getAccount,
getPermissionId,
getSessionNonce,
} from "@rhinestone/module-sdk";
const publicClient = getClient({
rpcUrl: "https://sepolia.drpc.org", // or your own rpc url
});
const account = await getAccount({
address: safeAccount.address,
type: "safe",
});
const permissionId = (await getPermissionId({
client: publicClient,
session,
})) as Hex;
const sessionNonce = await getSessionNonce({
client: publicClient,
account,
permissionId,
});
Get the digests and hash to sign
Next, we will get various digests in order to formaute the hash to sign. The signature over this hash is what allows the new session to be enabled.
import {
getSessionDigest,
SmartSessionMode,
SMART_SESSIONS_ADDRESS,
hashChainSessions,
} from "@rhinestone/module-sdk";
import { sepolia } from "viem/chains";
const sessionDigest = await getSessionDigest({
client: publicClient,
account,
session,
mode: SmartSessionMode.ENABLE,
permissionId,
});
const chainDigests = [
{
chainId: BigInt(sepolia.id), // or your current chain
sessionDigest,
},
];
const chainSessions: ChainSession[] = [
{
chainId: BigInt(sepolia.id),
session: {
...session,
account: account.address,
smartSession: SMART_SESSIONS_ADDRESS,
mode: 1,
nonce: sessionNonce,
},
},
];
const permissionEnableHash = hashChainSessions(chainSessions);
Have the user sign the enable signature
Next, the user will need to sign the permissionEnableHash
. How exactly this is done depends on how your application works and which validator the user currently has installed.
In this case, we will assume that an ownable validator is already installed on the account, so we can use the users' EOA to sign the hash.
const permissionEnableSig = await signer.signMessage({
message: { raw: permissionEnableHash },
});
Create the UserOperation to execute
Now that we have the session set up, we can create the UserOperation. Since we scope this session for a specific target and seletor, we will use those to create the calldata for the UserOperation.
const callData = await safeAccount.encodeCallData({
to: session.actions[0].actionTarget,
data: 0,
value: session.actions[0].actionTargetSelector,
});
// only if using pimlico
const gasPrices = await pimlicoBundlerClient.getUserOperationGasPrice();
const userOperation = await smartAccountClient.prepareUserOperationRequest({
userOperation: {
callData, // callData is the only required field in the partial user operation
maxFeePerGas: gasPrices.fast.maxFeePerGas,
maxPriorityFeePerGas: gasPrices.fast.maxPriorityFeePerGas,
},
});
Gas estimation
Before wew can sign the UserOperation, we will need to estimate the gas. For this, we will need a dummy signature that is as close to the real signature as possible.
// Todo
Format the signature
Next, we will use the session key to sign the UserOperation.
import { getUserOperationHash, ENTRYPOINT_ADDRESS_V07 } from "permissionless";
import {encodeSmartSessionSignature, SmartSessionMode, OWNABLE_VALIDATOR_ADDRESS}
const userOpHash = getUserOperationHash({
userOperation,
chainId: sepolia.id,
entryPoint: ENTRYPOINT_ADDRESS_V07,
});
const signature = await sessionOwner.signMessage({
message: { raw: userOpHash },
})
userOperation.signature = encodeSmartSessionSignature({
mode: SmartSessionMode.ENABLE,
permissionId,
signature,
enableSessionData: {
enableSession: {
chainDigestIndex: 0,
hashesAndChainIds: chainDigests,
sessionToEnable: session,
permissionEnableSig,
},
validator: OWNABLE_VALIDATOR_ADDRESS,
accountType: 'safe',
},
})
Execute the UserOperation
Finally, we can execute the UserOperation to recover the account.
const userOpHash = await smartAccountClient.sendUserOperation({
userOperation,
});