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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sendop",
"version": "0.5.6",
"version": "0.5.7",
"description": "ERC-4337 utilities for sending user operations with ethers.js",
"author": "Johnson Chen <johnson86tw@gmail.com>",
"repository": "https://github.com/ethaccount/sendop",
Expand Down
19 changes: 18 additions & 1 deletion src/core/UserOpBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,22 @@ export class UserOpBuilder {
paymaster,
paymasterData = '0x',
paymasterPostOpGasLimit = 0,
paymasterVerificationGasLimit = 0,
}: {
paymaster: string
paymasterData?: string
paymasterPostOpGasLimit?: BigNumberish
paymasterVerificationGasLimit?: BigNumberish
}): UserOpBuilder {
this.userOp.paymaster = paymaster
this.userOp.paymasterData = paymasterData
this.userOp.paymasterPostOpGasLimit = paymasterPostOpGasLimit
this.userOp.paymasterVerificationGasLimit = paymasterVerificationGasLimit
return this
}

setPaymasterData(paymasterData: string): UserOpBuilder {
this.userOp.paymasterData = paymasterData
return this
}

Expand Down Expand Up @@ -233,6 +241,11 @@ export class UserOpBuilder {
return [domain, types, this.pack()]
}

/**
* Estimates gas values for the user operation using the bundler.
* Always sets: verificationGasLimit, preVerificationGas, callGasLimit
* Conditionally sets: paymasterVerificationGasLimit (only if not already set), maxFeePerGas, maxPriorityFeePerGas
*/
async estimateGas(): Promise<void> {
this.checkSender()
this.checkEntryPointAddress()
Expand All @@ -242,7 +255,11 @@ export class UserOpBuilder {
this.userOp.verificationGasLimit = estimations.verificationGasLimit
this.userOp.preVerificationGas = estimations.preVerificationGas
this.userOp.callGasLimit = estimations.callGasLimit
this.userOp.paymasterVerificationGasLimit = estimations.paymasterVerificationGasLimit

// Only set paymasterVerificationGasLimit if not already set
if (!this.userOp.paymasterVerificationGasLimit) {
this.userOp.paymasterVerificationGasLimit = estimations.paymasterVerificationGasLimit
}

// Only etherspot returns these
if (estimations.maxFeePerGas) {
Expand Down
85 changes: 85 additions & 0 deletions src/paymasters/PaymasterService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { toUserOpHex, type UserOperation } from '@/core'
import type { AddressLike, BigNumberish, FetchRequest, JsonRpcApiProviderOptions } from 'ethers'
import { JsonRpcProvider, resolveAddress, toBeHex } from 'ethers'
import type {
GetPaymasterDataParams,
GetPaymasterDataResult,
GetPaymasterStubDataParams,
GetPaymasterStubDataResult,
} from './erc7677-types'

export class PaymasterServiceError extends Error {
constructor(message: string, cause?: unknown) {
super(message, { cause })
this.name = 'PaymasterServiceError'
}
}

export class PaymasterService extends JsonRpcProvider {
chainId: number

constructor(url: string | FetchRequest, chainId: BigNumberish, options?: JsonRpcApiProviderOptions) {
const serviceOptions = {
...options,
batchMaxCount: options?.batchMaxCount ? 1 : options?.batchMaxCount,
staticNetwork: options?.staticNetwork ?? true,
}
const chainIdNumber = Number(chainId)
super(url, chainIdNumber, serviceOptions)
this.chainId = chainIdNumber
}

/**
* Get supported entry points for this paymaster
* @returns Array of supported entry point addresses
*/
async supportedEntryPoints(): Promise<string[]> {
return await this.send('pm_supportedEntryPoints', [])
}

/**
* Get paymaster stub data for a user operation
*/
async getPaymasterStubData({
userOp,
entryPointAddress,
context,
}: {
userOp: UserOperation
entryPointAddress: AddressLike
context: Record<string, any>
}): Promise<GetPaymasterStubDataResult | null> {
let params: GetPaymasterStubDataParams
try {
params = [toUserOpHex(userOp), await resolveAddress(entryPointAddress), toBeHex(this.chainId), context]
} catch (err) {
throw new PaymasterServiceError('Error building params for pm_getPaymasterStubData', {
cause: err,
})
}
return await this.send('pm_getPaymasterStubData', params)
}

/**
* Get final paymaster data for a user operation
*/
async getPaymasterData({
userOp,
entryPointAddress,
context,
}: {
userOp: UserOperation
entryPointAddress: AddressLike
context: Record<string, any>
}): Promise<GetPaymasterDataResult | null> {
let params: GetPaymasterDataParams
try {
params = [toUserOpHex(userOp), await resolveAddress(entryPointAddress), toBeHex(this.chainId), context]
} catch (err) {
throw new PaymasterServiceError('Error building params for pm_getPaymasterData', {
cause: err,
})
}
return await this.send('pm_getPaymasterData', params)
}
}
33 changes: 33 additions & 0 deletions src/paymasters/erc7677-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// https://eips.ethereum.org/EIPS/eip-7677

import type { UserOperationHex } from '../core'

// [userOp, entryPoint, chainId, context]
export type GetPaymasterStubDataParams = [
UserOperationHex, // userOp
string, // EntryPoint
string, // Chain ID
Record<string, any>, // Context
]

export type GetPaymasterStubDataResult = {
sponsor?: { name: string; icon?: string } // Sponsor info
paymaster: string // Paymaster address
paymasterData: string // Paymaster data
paymasterVerificationGasLimit: string // Paymaster validation gas
paymasterPostOpGasLimit: string // Paymaster post-op gas
isFinal?: boolean // Indicates that the caller does not need to call pm_getPaymasterData
}

// [userOp, entryPoint, chainId, context]
export type GetPaymasterDataParams = [
UserOperationHex, // userOp
string, // Entrypoint
string, // Chain ID
Record<string, any>, // Context
]

export type GetPaymasterDataResult = {
paymaster: string // Paymaster address
paymasterData: string // Paymaster data
}
2 changes: 2 additions & 0 deletions src/paymasters/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from './public-paymaster'
export * from './PaymasterService'
export * from './erc7677-types'
2 changes: 1 addition & 1 deletion stats.html

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions test/scripts/candide.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { KernelAccountAPI } from '@/accounts'
import { ADDRESS } from '@/addresses'
import { ERC4337Bundler } from '@/core'
import { fetchGasPriceAlchemy, fetchGasPricePimlico } from '@/fetchGasPrice'
import { fetchGasPricePimlico } from '@/fetchGasPrice'
import { INTERFACES } from '@/interfaces'
import { getECDSAValidator } from '@/validations/getECDSAValidator'
import { SingleEOAValidation } from '@/validations/SingleEOAValidation'
import { getBytes, JsonRpcProvider, Wallet } from 'ethers'
import { alchemy, pimlico } from 'evm-providers'
import { buildAccountExecutions } from '../helpers'
import { getPublicPaymaster } from '@/paymasters'

// - Candide does not support batching, so you must set `batchMaxCount: 1` on the JSON RPC provider.
// - Candide does not support public paymasters, and will throw an error: `ERC4337Error: AA33 revertedb''`.
Expand Down
118 changes: 118 additions & 0 deletions test/scripts/execute-kernel-candide-paymaster.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { KernelAccountAPI } from '@/accounts'
import { ADDRESS } from '@/addresses'
import { ERC4337Bundler } from '@/core'
import { fetchGasPricePimlico } from '@/fetchGasPrice'
import { INTERFACES } from '@/interfaces'
import { PaymasterService } from '@/paymasters/PaymasterService'
import { isSameAddress } from '@/utils'
import { getECDSAValidator } from '@/validations/getECDSAValidator'
import { SingleEOAValidation } from '@/validations/SingleEOAValidation'
import { getAddress, getBytes, JsonRpcProvider, Wallet } from 'ethers'
import { alchemy, pimlico } from 'evm-providers'
import { buildAccountExecutions } from '../helpers'

const { ALCHEMY_API_KEY = '', PIMLICO_API_KEY = '', DEV_7702_PK = '', CANDIDE_API_KEY = '' } = process.env

if (!ALCHEMY_API_KEY) {
throw new Error('ALCHEMY_API_KEY is not set')
}
if (!PIMLICO_API_KEY) {
throw new Error('PIMLICO_API_KEY is not set')
}
if (!CANDIDE_API_KEY) {
throw new Error('CANDIDE_API_KEY is not set')
}

const CHAIN_ID = 84532
const POLICY_ID = 'f0785f78e6678a99'

const rpcUrl = alchemy(CHAIN_ID, ALCHEMY_API_KEY)
const pimlicoUrl = pimlico(CHAIN_ID, PIMLICO_API_KEY)
const candideUrl = `https://api.candide.dev/api/v3/${CHAIN_ID}/${CANDIDE_API_KEY}`
const paymasterUrl = `https://api.candide.dev/paymaster/v3/base-sepolia/${CANDIDE_API_KEY}`

const client = new JsonRpcProvider(rpcUrl)
const bundler = new ERC4337Bundler(pimlicoUrl, undefined, {
batchMaxCount: 1, // candide doesn't support rpc batching
})
const paymasterService = new PaymasterService(paymasterUrl, CHAIN_ID)

const signer = new Wallet(DEV_7702_PK)

const ecdsaValidator = getECDSAValidator({ ownerAddress: signer.address })

const accountAddress = '0x960CBf515F3DcD46f541db66C76Cf7acA5BEf4C7'

const kernelAPI = new KernelAccountAPI({
validation: new SingleEOAValidation(),
validatorAddress: ecdsaValidator.address,
})

const executions = [
{
to: ADDRESS.Counter,
value: 0n,
data: INTERFACES.Counter.encodeFunctionData('increment'),
},
]

const op = await buildAccountExecutions({
accountAPI: kernelAPI,
accountAddress,
chainId: CHAIN_ID,
client,
bundler,
executions,
})

if (!op.entryPointAddress) {
throw new Error('Entry point address is not set')
}

const entryPointAddress = getAddress(op.entryPointAddress)

const supportedEntryPoints = await paymasterService.supportedEntryPoints()

if (!supportedEntryPoints.some((entryPoint: string) => isSameAddress(entryPoint, entryPointAddress))) {
throw new Error('Entry point not supported by paymaster')
}

const paymasterStubData = await paymasterService.getPaymasterStubData({
userOp: op.preview(),
entryPointAddress: op.entryPointAddress,
context: { sponsorshipPolicyId: POLICY_ID },
})

if (!paymasterStubData) {
throw new Error('Paymaster stub data is falsy')
}
console.log('paymasterStubData', paymasterStubData)

op.setPaymaster(paymasterStubData)

op.setGasPrice(await fetchGasPricePimlico(pimlicoUrl))

await op.estimateGas()

if (!paymasterStubData.isFinal) {
const paymasterData = await paymasterService.getPaymasterData({
userOp: op.preview(),
entryPointAddress: op.entryPointAddress,
context: { sponsorshipPolicyId: POLICY_ID },
})
console.log('paymasterData', paymasterData)

if (!paymasterData) {
throw new Error('Paymaster data is falsy')
}

op.setPaymasterData(paymasterData.paymasterData)
}

const sig = await signer.signMessage(getBytes(op.hash()))

op.setSignature(await kernelAPI.formatSignature(sig))

await op.send()
const receipt = await op.wait()
console.log('receipt', receipt.success)