@biconomy/abstractjs
Version:
SDK for Biconomy integration with support for account abstraction, smart accounts, ERC-4337.
552 lines • 27 kB
JavaScript
import { decodeAbiParameters, encodeAbiParameters, encodePacked, erc20Abi, getAbiItem, getAddress, keccak256, parseAbiParameters, parseUnits, toFunctionSelector, zeroAddress } from "viem";
import { UniversalPolicyAbi, batchInstructions, buildActionPolicy, buildSessionAction, calldataArgument, getUniversalActionPolicyConditionType, resolveInstructions, resolveSessionActions, versionIsAtLeast } from "../../../account/index.js";
import { toBytes32 } from "../../../account/utils/Utils.js";
import { DEFAULT_MEE_VERSION, MEEVersion, NexusImplementationAbi, SMART_SESSIONS_ADDRESS, SmartSessionAbi, SmartSessionMode, UNIVERSAL_ACTION_POLICY_ADDRESS, getOwnableValidatorMockSignature, getPermissionId, getUniversalActionPolicy } from "../../../constants/index.js";
import { ConditionType, createCondition, generateSalt, getMEEVersion } from "../../../modules/index.js";
import { toInstallWithSafeSenderCalls } from "../erc7579/installModule.js";
import { isModuleInstalled } from "../erc7579/isModuleInstalled.js";
import { parseModuleTypeId } from "../erc7579/supportsModule.js";
import getFusionQuote, {} from "./getFusionQuote.js";
import getQuote, {} from "./getQuote.js";
export const getSessionValidatorInitData = (deploymentVersion, redeemer) => {
const isStxValidator = versionIsAtLeast(deploymentVersion.version, MEEVersion.V3_0_0);
return isStxValidator
? encodePacked(["address", "uint8", "address"], [deploymentVersion.submodules?.EoaStatelessValidator, 0, redeemer])
: redeemer;
};
export const prepareInstallSmartSessions = async (client, smartSessionValidatorAddress = SMART_SESSIONS_ADDRESS) => {
// check if we need to install the module on any of the chains
// it includes the deployment of the account on the chains if needed
// because knowing the account is not deployed on a chain, means the module has not been installed on that chain
// preparing the installation instruction means that when the instruction is going to be converted to the userOp,
// the account will be deployed (userOp.initCode provided if needed)
const installInstructions = await Promise.all(client.account.deployments.map(async (deployment) => {
//sanity check
const chainId = deployment.client.chain?.id;
if (!chainId) {
throw new Error("Chain ID is not set");
}
const isModuleInstalled_ = (await deployment.isDeployed())
? await isModuleInstalled(undefined, {
account: deployment,
module: {
address: smartSessionValidatorAddress,
initData: "0x",
type: "validator"
}
})
: false;
// it will also include the deployment instruction if needed
if (!isModuleInstalled_) {
const installModuleCalls = await toInstallWithSafeSenderCalls(deployment, {
address: smartSessionValidatorAddress,
initData: "0x",
type: "validator"
});
const installModuleInstructions = [];
for (const installModuleCall of installModuleCalls) {
const instruction = await client.account.buildComposable({
type: "rawCalldata",
data: {
to: installModuleCall.to,
calldata: installModuleCall.data,
value: installModuleCall.value,
chainId,
metadata: [
{
type: "CUSTOM",
description: "Install smart sessions module",
chainId
}
]
}
});
installModuleInstructions.push(...instruction);
}
return await client.account.buildComposable({
type: "batch",
data: {
instructions: installModuleInstructions
}
});
}
return undefined;
}));
return installInstructions.filter((inx) => inx !== undefined).flat();
};
export const prepareEnableSessions = async (client, enableSession,
// Default to official smart session validator address
smartSessionValidatorAddress = SMART_SESSIONS_ADDRESS, feeToken) => {
const { redeemer, maxPaymentAmount: maxPaymentAmount_, actions: unresolvedSessionActions,
// Actions are batched by default
batchActions = true } = enableSession;
const sessionActions = resolveSessionActions(unresolvedSessionActions);
if (!redeemer) {
throw new Error("Smart session redeemer address is missing");
}
if (!sessionActions || sessionActions.length === 0) {
throw new Error("Smart sessions actions are missing");
}
for (const { actions, chainId } of sessionActions) {
if (actions.length === 0) {
throw new Error(`Smart sessions actions are empty for the chain (${chainId})`);
}
}
let maxPaymentAmount = maxPaymentAmount_ ?? 0n;
if (feeToken && !maxPaymentAmount_) {
const { publicClient } = client.account.deploymentOn(feeToken.chainId, true);
const decimals = await publicClient.readContract({
address: feeToken.address,
abi: erc20Abi,
functionName: "decimals"
});
maxPaymentAmount = parseUnits("5", decimals);
}
// This will be always true for MEE flows
const permitERC4337Paymaster = true;
const uniqueChainIds = Array.from(new Set(sessionActions.map((sessionAction) => sessionAction.chainId)));
const enableSessionsInstructionsWithSessionDetails = await Promise.all(uniqueChainIds.map(async (chainId) => {
const deployment = client.account.deployments.find((deployment) => deployment.client.chain?.id === chainId);
if (!deployment) {
throw new Error(`Multichain Nexus is not configured on chain ${chainId}`);
}
let sessionActionsForChain = sessionActions.filter((sessionAction) => sessionAction.chainId === chainId);
if (feeToken && feeToken.chainId === chainId) {
sessionActionsForChain = addPaymentPolicyForActions(sessionActionsForChain, feeToken, maxPaymentAmount);
}
const defaultVersionConfig = getMEEVersion(DEFAULT_MEE_VERSION);
const deploymentVersion = deployment.version;
// MEE K1 validator or Stateless stx vaidator is our session validator based on version
const validatorAddress = deploymentVersion.validatorAddress ||
defaultVersionConfig.validatorAddress;
const sessionValidatorInitData = getSessionValidatorInitData(deploymentVersion, redeemer);
if (batchActions && sessionActionsForChain.length > 1) {
sessionActionsForChain = client.account.buildSessionAction({
type: "batch",
data: {
actions: sessionActionsForChain
}
});
}
const session = {
sessionValidator: validatorAddress,
sessionValidatorInitData,
salt: generateSalt(),
userOpPolicies: permitERC4337Paymaster
? [buildActionPolicy({ type: "sudo" })]
: [],
erc7739Policies: { allowedERC7739Content: [], erc1271Policies: [] },
// If the actions are batched ? all the actions will be available in first elements itself
// If its unbatched ? It will be reassigned below
actions: sessionActionsForChain[0].actions,
permitERC4337Paymaster,
chainId: BigInt(chainId)
};
const sessionDetailsSignature = getOwnableValidatorMockSignature({
threshold: 1
});
const permissionId = getPermissionId({
session: session
});
let enableSessionInstructions = [];
if (!batchActions) {
const condition = createCondition({
targetContract: deployment.address,
functionAbi: NexusImplementationAbi,
functionName: "isModuleInstalled",
args: [
parseModuleTypeId("validator"),
getAddress(smartSessionValidatorAddress),
"0x"
],
value: true,
type: ConditionType.EQ,
description: "Smart sessions module must be installed"
});
for (const { actions } of sessionActionsForChain) {
const sessionGroup = {
...session,
actions: actions
};
const instructions = await client.account.buildComposable({
type: "default",
data: {
abi: SmartSessionAbi,
functionName: "enableSessions",
args: [[sessionGroup]],
to: SMART_SESSIONS_ADDRESS,
chainId,
conditions: [condition],
simulationOverrides: {
customOverrides: getCustomStateOverridesForIsModuleInstalled(smartSessionValidatorAddress, deployment.address, chainId)
},
metadata: [
{
type: "CUSTOM",
description: "Enable smart sessions permissions",
chainId
}
]
}
});
enableSessionInstructions.push(...instructions);
}
}
else {
enableSessionInstructions = await client.account.buildComposable({
type: "default",
data: {
abi: SmartSessionAbi,
functionName: "enableSessions",
args: [[session]],
to: SMART_SESSIONS_ADDRESS,
chainId,
metadata: [
{
type: "CUSTOM",
description: "Enable smart sessions permissions",
chainId
}
]
}
});
}
return {
instructions: enableSessionInstructions,
sessionDetails: {
// This will be always use mode
mode: SmartSessionMode.USE,
permissionId,
signature: sessionDetailsSignature,
// This is just a dummy enableSessionData with zero values to be compatible with rest of the codebase
// However this will be completely ignored if USE mode is used
enableSessionData: {
enableSession: {
chainDigestIndex: 0,
hashesAndChainIds: [
{
chainId: session.chainId,
sessionDigest: "0x"
}
],
sessionToEnable: session,
permissionEnableSig: "0x"
},
validator: zeroAddress,
accountType: "nexus"
}
}
};
}));
return enableSessionsInstructionsWithSessionDetails;
};
export const getCustomStateOverridesForIsModuleInstalled = (validatorAddress, accountAddress, chainId) => {
// EERC-7201 namespaced storage slot where the AccountStorage struct starts for Nexus
const STORAGE_LOCATION = "0x0bb70095b32b9671358306b0339b4c06e7cbd8cb82505941fba30d1eb5b82f00";
// A SentinelList is a circular linked list that uses a special "SENTINEL" address (0x1)
const SENTINEL = "0x0000000000000000000000000000000000000001";
const getValidatorSlot = (validatorAddress) => {
const encoded = encodeAbiParameters(parseAbiParameters("address, bytes32"), [validatorAddress, STORAGE_LOCATION]);
return keccak256(encoded);
};
const customOverrides = [];
const customOverrideForValidator = {
contractAddress: accountAddress,
storageSlot: getValidatorSlot(validatorAddress),
chainId,
value: toBytes32(SENTINEL)
};
customOverrides.push(customOverrideForValidator);
return customOverrides;
};
export const addPaymentPolicyForActions = (sessionActionsForChain, feeToken, maxPaymentAmount) => {
const transferSelector = toFunctionSelector(getAbiItem({ abi: erc20Abi, name: "transfer" }));
let updatedSessionActionsForChain = sessionActionsForChain.map((sessionAction) => {
const updatedActions = sessionAction.actions.map((action) => {
if (action.actionTargetSelector.toLowerCase() ===
transferSelector.toLowerCase() &&
action.actionTarget.toLowerCase() === feeToken.address.toLowerCase()) {
const updatedActionPolicies = action.actionPolicies.map((actionPolicy) => {
if (actionPolicy.policy.toLowerCase() ===
UNIVERSAL_ACTION_POLICY_ADDRESS.toLowerCase()) {
const policyData = decodeAbiParameters(UniversalPolicyAbi, actionPolicy.initData);
const universalPolicyData = policyData[0];
// greaterThan and greaterThanOrEqual conditions already cover the gas payment amount so no need to
// modify the amount to include payment amount.
const conditions = [
getUniversalActionPolicyConditionType("equal"),
getUniversalActionPolicyConditionType("lessThan"),
getUniversalActionPolicyConditionType("lessThanOrEqual")
];
let isPaymentPolicyRuleAdded = false;
const updatedRules = universalPolicyData.paramRules.rules.map((rule) => {
if (!isPaymentPolicyRuleAdded &&
rule.offset === calldataArgument(2)) {
isPaymentPolicyRuleAdded = true;
}
// If calldata argument is amount field + expected conditions, add payment amount
if (rule.offset === calldataArgument(2) &&
conditions.includes(rule.condition)) {
let updatedRule = {
...rule,
ref: toBytes32(BigInt(rule.ref) + maxPaymentAmount)
};
// Is cumulative tracking is configured ? Increase the max amount limit as well
if (updatedRule.isLimited !== undefined &&
updatedRule.usage !== undefined) {
updatedRule = {
...updatedRule,
usage: {
...updatedRule.usage,
limit: updatedRule.usage.limit + maxPaymentAmount
}
};
}
return updatedRule;
}
return rule;
});
if (isPaymentPolicyRuleAdded) {
return getUniversalActionPolicy({
valueLimitPerUse: universalPolicyData.valueLimitPerUse,
paramRules: {
length: universalPolicyData.paramRules.length,
rules: updatedRules
}
});
}
// If the rules are already 16 in length, The payment policy rule cannot be added to an existing universal policy
if (universalPolicyData.paramRules.length >= 16) {
throw new Error("Failed to add payment policy for the supertransaction. There is policy conflicts within the defined universal action policies");
}
// New payment policy rule is added in the next free rule slot
updatedRules[Number(universalPolicyData.paramRules.length)] = {
condition: getUniversalActionPolicyConditionType("lessThanOrEqual"),
offset: calldataArgument(2),
ref: toBytes32(maxPaymentAmount),
isLimited: false,
usage: { used: 0n, limit: 0n }
};
return getUniversalActionPolicy({
valueLimitPerUse: universalPolicyData.valueLimitPerUse,
paramRules: {
// Payment policy rule is not added, so increasing a rule
length: universalPolicyData.paramRules.length + 1n,
rules: updatedRules
}
});
}
return actionPolicy;
});
return { ...action, actionPolicies: updatedActionPolicies };
}
return action;
});
return {
...sessionAction,
actions: updatedActions
};
});
const isPolicyForPaymentTokenExists = updatedSessionActionsForChain.some((sessionAction) => {
return sessionAction.actions.some((action) => {
const isFeeTokenTransferAction = action.actionTargetSelector.toLowerCase() ===
transferSelector.toLowerCase() &&
action.actionTarget.toLowerCase() === feeToken.address.toLowerCase();
if (isFeeTokenTransferAction) {
return action.actionPolicies.some((actionPolicy) => {
if (actionPolicy.policy.toLowerCase() ===
UNIVERSAL_ACTION_POLICY_ADDRESS.toLowerCase()) {
// At this place, the existing universal policy should be updated to include the payment amount in transfer action
return true;
}
// We have to add a new universal policy for payment
return false;
});
}
return false;
});
});
if (!isPolicyForPaymentTokenExists) {
let isPaymentPolicyAdded = false;
const [paymentAction] = buildSessionAction({
type: "transfer",
data: {
chainIds: [feeToken.chainId],
contractAddress: feeToken.address,
amountLimitPerAction: maxPaymentAmount,
maxAmountLimit: maxPaymentAmount
}
});
updatedSessionActionsForChain = updatedSessionActionsForChain.map((sessionAction) => {
// Payment policy will be added into the first session action for the payment chain so it will be always batched
if (!isPaymentPolicyAdded &&
sessionAction.chainId === feeToken.chainId) {
isPaymentPolicyAdded = true;
return {
...sessionAction,
actions: [...sessionAction.actions, ...paymentAction.actions]
};
}
return sessionAction;
});
}
return updatedSessionActionsForChain;
};
/**
* Gets a session quote for a set of actions and session configuration using the MEE service.
* This method prepares a quote either for PREPARE mode (for deploying or enabling a session)
* or for USE mode (for using an already enabled session).
*
* @param client - The base MEE client instance
* @param parameters - The parameters for the quote request, including instructions, session configuration, mode, and optionally trigger details and fee token
* @returns Promise resolving to a GetSessionQuoteResponse containing the quote payload and optional session details
*
* @example
* ```typescript
* const sessionQuote = await getSessionQuote(meeClient, {
* mode: "PREPARE",
* smartSessionValidatorAddress: '0x',
* enableSession: sessionEnableDetails,
* feeToken: { address: ..., chainId: ... },
* instructions: [ ... ],
* });
* ```
*
* @example
* ```typescript
* const sessionQuote = await getSessionQuote(meeClient, {
* mode: "USE",
* sessionDetails: [ ... ],
* feeToken: { address: ..., chainId: ... },
* instructions: [ ... ]
* });
* ```
*/
export const getSessionQuote = async (client, parameters) => {
const { mode,
// By default, emtpy instructions
instructions = [], feeToken, trigger,
// By default the batch will be true
batch = true } = parameters;
const meeVersions = client.account.deployments.map(({ version, chain }) => ({
chainId: chain.id,
version
}));
if (mode === "PREPARE") {
const { smartSessionValidatorAddress, enableSession } = parameters;
const batchActions = enableSession?.batchActions ?? true;
// Prepare session validator install instructions
const sessionValidatorInstallInstructions = await prepareInstallSmartSessions(client, smartSessionValidatorAddress);
const hasSessionValidatorInstallInstructions = sessionValidatorInstallInstructions.length > 0;
// Prepare session enable instructions
const enableSessionsInstructions = [];
const sessionDetailsArray = [];
if (enableSession) {
const enableSessionsInstructionsWithSessionDetails = await prepareEnableSessions(client, enableSession, smartSessionValidatorAddress, feeToken);
for (const { instructions, sessionDetails } of enableSessionsInstructionsWithSessionDetails) {
sessionDetailsArray.push(sessionDetails);
enableSessionsInstructions.push(...instructions);
}
}
const hasEnableSessionsInstructions = enableSessionsInstructions.length > 0;
// Additional instructions
const hasInstructions = instructions.length > 0;
// Funding instructions
const hasFundingRequest = !!trigger;
if (!hasSessionValidatorInstallInstructions &&
!hasEnableSessionsInstructions &&
!hasInstructions &&
!hasFundingRequest) {
return undefined;
}
const resolvedInstructions = await resolveInstructions([
...sessionValidatorInstallInstructions,
...instructions
]);
let partiallyBatchedInstructions = [];
if (batch) {
// By default, fund nexus, install SS module, deploy nexus will be batched
// Even if we wanted to unbatch actions into multiple userOps ? The additional instructions and install SS will be
// optimistically batched while the enable permissions actions will be unbatched down the line
partiallyBatchedInstructions = await batchInstructions({
accountAddress: client.account.signer.address,
meeVersions,
instructions: [...resolvedInstructions]
});
}
else {
// If batch: false is explicitly defined ? Everything will be unbatched.
partiallyBatchedInstructions = [...resolvedInstructions];
}
const finalInstructions = [
...partiallyBatchedInstructions,
...enableSessionsInstructions
];
// If batch actions is disabled and there are enable permission inxs ? The quote will be unbatched
const isUnbatchActionsRequired = !batchActions && hasEnableSessionsInstructions;
const {
// Avoiding these params to be passed forward
mode: _avoidOne, smartSessionValidatorAddress: _avoidTwo, enableSession: _avoidThree, ...rest } = parameters;
if (hasFundingRequest) {
// Safe and Delegated smart accounts are not supported here
const fusionQuote = await getFusionQuote(client, {
...rest,
trigger: rest.trigger,
feePayer: undefined,
batch: isUnbatchActionsRequired ? false : batch,
instructions: finalInstructions
});
return {
quoteType: fusionQuote.quote.quoteType,
quote: fusionQuote,
...(sessionDetailsArray.length > 0
? { sessionDetails: sessionDetailsArray }
: {})
};
}
const quote = await getQuote(client, {
...rest,
batch: isUnbatchActionsRequired ? false : batch,
instructions: finalInstructions
});
return {
quoteType: quote.quoteType,
quote,
...(sessionDetailsArray.length > 0
? { sessionDetails: sessionDetailsArray }
: {})
};
}
const { sessionDetails,
// Avoiding these params to be passed forward
mode: _avoidOne, ...rest } = parameters;
const isEnableAndUseSessionDetailExists = sessionDetails.some((sessionDetailsInfo) => sessionDetailsInfo.mode === SmartSessionMode.UNSAFE_ENABLE);
if (isEnableAndUseSessionDetailExists) {
throw new Error("ENABLE_AND_USE mode is not supported, session details is invalid.");
}
const quote = await getQuote(client, {
...rest,
moduleAddress: SMART_SESSIONS_ADDRESS,
shortEncodingSuperTxn: true,
// The mode will be always USE.
smartSessionMode: "USE",
sessionDetails
});
const startIndex = quote.paymentInfo.sponsored ? 1 : 0;
for (const [index, userOp] of quote.userOps.entries()) {
// Skip payment userOp if sponsored
if (index < startIndex)
continue;
const relevantIndex = sessionDetails.findIndex(({ enableSessionData }) => enableSessionData?.enableSession?.sessionToEnable?.chainId ===
BigInt(userOp.chainId));
if (relevantIndex === -1) {
throw new Error(`No session details found for chainId ${userOp.chainId}`);
}
userOp.sessionDetails = sessionDetails[relevantIndex];
}
return {
quoteType: quote.quoteType,
quote
};
};
//# sourceMappingURL=getSessionQuote.js.map