Skip to content

Commit

Permalink
chore: batch session with token gas payments (#505)
Browse files Browse the repository at this point in the history
* chore: support erc20 payment session
  • Loading branch information
joepegler authored May 30, 2024
1 parent 97c8fcd commit 6678f24
Show file tree
Hide file tree
Showing 17 changed files with 645 additions and 4,984 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ For a comprehensive understanding of our project and to contribute effectively,
- [send some eth with sponsorship](examples/SEND_SOME_ETH_WITH_SPONSORSHIP.md)
- [send a multi tx and pay gas with an erc20 token](examples/SEND_A_MULTI_TX_AND_PAY_GAS_WITH_TOKEN.md)
- [create and use a session](examples/CREATE_AND_USE_A_SESSION.md)
- [create and use a batch session](examples/CREATE_AND_USE_A_BATCH_SESSION.md)

## License

Expand Down
Binary file modified bun.lockb
Binary file not shown.
26 changes: 24 additions & 2 deletions examples/CREATE_AND_USE_A_SESSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,32 @@ const { sessionKeyAddress, sessionStorageClient } = await createSessionKeyEOA(
chain
);

// The rules that govern the method from the whitelisted contract
/**
* Rule
*
* https://docs.biconomy.io/Modules/abiSessionValidationModule#rules
*
* Rules define permissions for the args of an allowed method. With rules, you can precisely define what should be the args of the transaction that is allowed for a given Session. Every Rule works with a single static arg or a 32-byte chunk of the dynamic arg.
* Since the ABI Encoding translates every static param into a 32-bytes word, even the shorter ones (like address or uint8), every Rule defines a desired relation (Condition) between n-th 32bytes word of the calldata and a reference Value (that is obviously a 32-bytes word as well).
* So, when dApp is creating a _sessionKeyData to enable a session, it should convert every shorter static arg to a 32bytes word to match how it will be actually ABI encoded in the userOp.callData.
* For the dynamic args, like bytes, every 32-bytes word of the calldata such as offset of the bytes arg, length of the bytes arg, and n-th 32-bytes word of the bytes arg can be controlled by a dedicated Rule.
*/
const rules: Rule = [
{
/** The index of the param from the selected contract function upon which the condition will be applied */
/**
*
* offset
*
* https://docs.biconomy.io/Modules/abiSessionValidationModule#rules
*
* The offset in the ABI SVM contract helps locate the relevant data within the function call data, it serves as a reference point from which to start reading or extracting specific information required for validation. When processing function call data, particularly in low-level languages like Solidity assembly, it's necessary to locate where specific parameters or arguments are stored. The offset is used to calculate the starting position within the calldata where the desired data resides. Suppose we have a function call with multiple arguments passed as calldata. Each argument occupies a certain number of bytes, and the offset helps determine where each argument begins within the calldata.
* Using the offset to Extract Data: In the contract, the offset is used to calculate the position within the calldata where specific parameters or arguments are located. Since every arg is a 32-bytes word, offsets are always multiplier of 32 (or of 0x20 in hex).
* Let's see how the offset is applied to extract the to and value arguments of a transfer(address to, uint256 value) method:
* - Extracting to Argument: The to argument is the first parameter of the transfer function, representing the recipient address. Every calldata starts with the 4-bytes method selector. However, the ABI SVM is adding the selector length itself, so for the first argument the offset will always be 0 (0x00);
* - Extracting value Argument: The value argument is the second parameter of the transfer function, representing the amount of tokens to be transferred. To extract this argument, the offset for the value parameter would be calculated based on its position in the function calldata. Despite to is a 20-bytes address, in the solidity abi encoding it is always appended with zeroes to a 32-bytes word. So the offset for the second 32-bytes argument (which isthe value in our case) will be 32 (or 0x20 in hex).
*
* If you need to deal with dynamic-length arguments, such as bytes, please refer to this document https://docs.soliditylang.org/en/v0.8.24/abi-spec.html#function-selector-and-argument-encoding to learn more about how dynamic arguments are represented in the calldata and which offsets should be used to access them.
*/
offset: 0,
/**
* Conditions:
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
"typings": "./dist/_types/index.d.ts",
"homepage": "https://biconomy.io",
"sideEffects": false,
"name": "@biconomy/account",
"name": "@biconomy-devx/account",
"author": "Biconomy",
"version": "4.4.4",
"version": "4.4.27",
"description": "SDK for Biconomy integration with support for account abstraction, smart accounts, ERC-4337.",
"keywords": [
"erc-7579",
Expand Down Expand Up @@ -58,6 +58,7 @@
"build": "bun run clean && bun run build:cjs && bun run build:esm && bun run build:types",
"clean": "rimraf ./dist/_esm ./dist/_cjs ./dist/_types ./dist/tsconfig",
"test": "vitest dev -c ./tests/vitest.config.ts",
"playground": "bun run test -t=Playground --watch",
"test:readOnly": "bun run test read",
"test:watch": "bun run test --watch",
"test:watch:readOnly": "bun run test:readOnly --watch",
Expand Down
2 changes: 2 additions & 0 deletions src/account/BiconomySmartAccountV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1227,10 +1227,12 @@ export class BiconomySmartAccountV2 extends BaseSmartContractAccount {
// biome-ignore lint/performance/noDelete: <explanation>
delete userOp.signature
const userOperation = await this.signUserOp(userOp, params)

const bundlerResponse = await this.sendSignedUserOp(
userOperation,
params?.simulationType
)

return bundlerResponse
}

Expand Down
3 changes: 3 additions & 0 deletions src/account/utils/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ export const BICONOMY_FACTORY_ADDRESSES: BiconomyFactories = {
"0x000000a56Aaca3e9a4C479ea6b6CD0DbcB6634F5": "V2_0_0"
}

export const BICONOMY_TOKEN_PAYMASTER =
"0x00000f7365cA6C59A2C93719ad53d567ed49c14C"

// will always be latest implementation address
export const DEFAULT_BICONOMY_IMPLEMENTATION_ADDRESS =
"0x0000002512019Dafb59528B82CB92D3c5D2423aC"
Expand Down
50 changes: 41 additions & 9 deletions src/modules/sessions/abi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ import {
Logger,
type Transaction
} from "../../account"
import { createSessionKeyManagerModule, resumeSession } from "../index"
import {
createSessionKeyManagerModule,
didProvideFullSession,
resumeSession
} from "../index"
import type { ISessionStorage } from "../interfaces/ISessionStorage"
import {
DEFAULT_ABI_SVM_MODULE,
Expand Down Expand Up @@ -190,11 +194,15 @@ export const createSession = async (
}
}

export type HardcodedFunctionSelector = {
raw: Hex
}

export type CreateSessionDatumParams = {
interval?: SessionEpoch
sessionKeyAddress: Hex
contractAddress: Hex
functionSelector: string | AbiFunction
functionSelector: string | AbiFunction | HardcodedFunctionSelector
rules: Rule[]
valueLimit: bigint
}
Expand Down Expand Up @@ -224,14 +232,32 @@ export const createABISessionDatum = ({
valueLimit
}: CreateSessionDatumParams): CreateSessionDataParams => {
const { validUntil = 0, validAfter = 0 } = interval ?? {}

let parsedFunctionSelector: Hex = "0x"

const rawFunctionSelectorWasProvided = !!(
functionSelector as HardcodedFunctionSelector
)?.raw

if (rawFunctionSelectorWasProvided) {
parsedFunctionSelector = (functionSelector as HardcodedFunctionSelector).raw
} else {
const unparsedFunctionSelector = functionSelector as AbiFunction | string
parsedFunctionSelector = slice(
toFunctionSelector(unparsedFunctionSelector),
0,
4
)
}

return {
validUntil,
validAfter,
sessionValidationModule: DEFAULT_ABI_SVM_MODULE,
sessionPublicKey: sessionKeyAddress,
sessionKeyData: getSessionDatum(sessionKeyAddress, {
destContract: contractAddress,
functionSelector: slice(toFunctionSelector(functionSelector), 0, 4),
functionSelector: parsedFunctionSelector,
valueLimit,
rules
})
Expand Down Expand Up @@ -284,6 +310,7 @@ export function getSessionDatum(
parseReferenceValue(permission.rules[i].referenceValue)
]) as Hex
}

return sessionKeyData
}

Expand Down Expand Up @@ -326,7 +353,7 @@ export type SingleSessionParamsPayload = {
*
* Retrieves the transaction parameters for a batched session.
*
* @param correspondingIndex - An index for the transaction corresponding to the relevant session
* @param correspondingIndex - An index for the transaction corresponding to the relevant session. If not provided, the last session index is used.
* @param conditionalSession - {@link SessionSearchParam} The session data that contains the sessionID and sessionSigner. If not provided, The default session storage (localStorage in browser, fileStorage in node backend) is used to fetch the sessionIDInfo
* @param chain - The chain.
* @returns Promise<{@link BatchSessionParamsPayload}> - session parameters.
Expand All @@ -335,22 +362,27 @@ export type SingleSessionParamsPayload = {
export const getSingleSessionTxParams = async (
conditionalSession: SessionSearchParam,
chain: Chain,
correspondingIndex = 0
correspondingIndex: number | null | undefined
): Promise<SingleSessionParamsPayload> => {
const { sessionStorageClient, sessionIDInfo } =
await resumeSession(conditionalSession)
const { sessionStorageClient } = await resumeSession(conditionalSession)

// if correspondingIndex is null then use the last session.
const allSessions = await sessionStorageClient.getAllSessionData()
const sessionID = didProvideFullSession(conditionalSession)
? (conditionalSession as Session).sessionIDInfo[correspondingIndex ?? 0]
: allSessions[correspondingIndex ?? allSessions.length - 1].sessionID

const sessionSigner = await sessionStorageClient.getSignerBySession(
{
sessionID: sessionIDInfo[0]
sessionID
},
chain
)

return {
params: {
sessionSigner,
sessionID: sessionIDInfo[correspondingIndex]
sessionID
}
}
}
37 changes: 28 additions & 9 deletions src/modules/sessions/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,20 @@ import {
type BuildUserOpOptions,
ERROR_MESSAGES,
Logger,
type Transaction
type Transaction,
isNullOrUndefined
} from "../../account"
import {
type CreateSessionDataParams,
DEFAULT_BATCHED_SESSION_ROUTER_MODULE,
DEFAULT_SESSION_KEY_MANAGER_MODULE,
type Session,
type SessionGrantedPayload,
type SessionParams,
type SessionSearchParam,
createBatchedSessionRouterModule,
createSessionKeyManagerModule,
didProvideFullSession,
resumeSession
} from "../index.js"
import type { ISessionStorage } from "../interfaces/ISessionStorage"
Expand Down Expand Up @@ -192,24 +195,40 @@ export type BatchSessionParamsPayload = {
* Retrieves the transaction parameters for a batched session.
*
* @param transactions - An array of {@link Transaction}s.
* @param correspondingIndexes - An array of indexes for the transactions corresponding to the relevant session
* @param correspondingIndexes - An array of indexes for the transactions corresponding to the relevant session. If not provided, the last {transaction.length} sessions are used.
* @param conditionalSession - {@link SessionSearchParam} The session data that contains the sessionID and sessionSigner. If not provided, The default session storage (localStorage in browser, fileStorage in node backend) is used to fetch the sessionIDInfo
* @param chain - The chain.
* @returns Promise<{@link BatchSessionParamsPayload}> - session parameters.
*
*/
export const getBatchSessionTxParams = async (
transactions: Transaction[],
correspondingIndexes: number[],
correspondingIndexes: number[] | null,
conditionalSession: SessionSearchParam,
chain: Chain
): Promise<BatchSessionParamsPayload> => {
if (correspondingIndexes.length !== transactions.length) {
if (
correspondingIndexes &&
correspondingIndexes.length !== transactions.length
) {
throw new Error(ERROR_MESSAGES.INVALID_SESSION_INDEXES)
}

const { sessionStorageClient, sessionIDInfo } =
await resumeSession(conditionalSession)
const { sessionStorageClient } = await resumeSession(conditionalSession)
let sessionIDInfo: string[] = []

const allSessions = await sessionStorageClient.getAllSessionData()
if (didProvideFullSession(conditionalSession)) {
sessionIDInfo = (conditionalSession as Session).sessionIDInfo
} else if (isNullOrUndefined(correspondingIndexes)) {
sessionIDInfo = allSessions
.slice(-transactions.length)
.map(({ sessionID }) => sessionID as string)
} else {
sessionIDInfo = (correspondingIndexes ?? []).map(
(index) => allSessions[index].sessionID as string
)
}

const sessionSigner = await sessionStorageClient.getSignerBySession(
{
Expand All @@ -220,10 +239,10 @@ export const getBatchSessionTxParams = async (

return {
params: {
batchSessionParams: correspondingIndexes.map(
(i): SessionParams => ({
batchSessionParams: sessionIDInfo.map(
(sessionID): SessionParams => ({
sessionSigner,
sessionID: sessionIDInfo[i]
sessionID
})
)
}
Expand Down
44 changes: 42 additions & 2 deletions src/modules/sessions/sessionSmartAccountClient.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { http, type Hex, createWalletClient } from "viem"
import { http, type Chain, type Hex, createWalletClient } from "viem"
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"
import {
type BiconomySmartAccountV2,
type BiconomySmartAccountV2Config,
type BuildUserOpOptions,
type SupportedSigner,
createSmartAccountClient,
getChain
} from "../../account"
Expand All @@ -13,7 +15,7 @@ import {
resumeSession
} from "../index.js"
import type { ISessionStorage } from "../interfaces/ISessionStorage"
import type { ModuleInfo } from "../utils/Types"
import type { ModuleInfo, StrictSessionParams } from "../utils/Types"

export type ImpersonatedSmartAccountConfig = Omit<
BiconomySmartAccountV2Config,
Expand Down Expand Up @@ -124,3 +126,41 @@ export const createSessionSmartAccountClient = async (
sessionData // contains the sessionSigner that will be used for txs
})
}

/**
*
* @param privateKey - The private key of the user's account
* @param chain - The chain object
* @returns {@link SupportedSigner} - A signer object that can be used to sign transactions
*/
export const toSupportedSigner = (
privateKey: string,
chain: Chain
): SupportedSigner => {
const parsedPrivateKey: Hex = privateKey.startsWith("0x")
? (privateKey as Hex)
: `0x${privateKey}`
const account = privateKeyToAccount(parsedPrivateKey)
return createWalletClient({
account,
chain,
transport: http()
})
}

/**
*
* @param privateKey The private key of the user's account
* @param sessionIDs An array of sessionIDs
* @param chain The chain object
* @returns {@link StrictSessionParams[]} - An array of session parameters {@link StrictSessionParams} that can be used to sign transactions here {@link BuildUserOpOptions}
*/
export const toSessionParams = (
privateKey: Hex,
sessionIDs: string[],
chain: Chain
): StrictSessionParams[] =>
sessionIDs.map((sessionID) => ({
sessionID,
sessionSigner: toSupportedSigner(privateKey, chain)
}))
Loading

0 comments on commit 6678f24

Please sign in to comment.