-
Notifications
You must be signed in to change notification settings - Fork 198
/
BaseAccountAPI.ts
322 lines (288 loc) · 11 KB
/
BaseAccountAPI.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
import { ethers, BigNumber, BigNumberish } from 'ethers'
import { Provider } from '@ethersproject/providers'
import {
EntryPoint, EntryPoint__factory,
UserOperationStruct
} from '@account-abstraction/contracts'
import { TransactionDetailsForUserOp } from './TransactionDetailsForUserOp'
import { resolveProperties } from 'ethers/lib/utils'
import { PaymasterAPI } from './PaymasterAPI'
import { getUserOpHash, NotPromise, packUserOp } from '@account-abstraction/utils'
import { calcPreVerificationGas, GasOverheads } from './calcPreVerificationGas'
export interface BaseApiParams {
provider: Provider
entryPointAddress: string
accountAddress?: string
overheads?: Partial<GasOverheads>
paymasterAPI?: PaymasterAPI
}
export interface UserOpResult {
transactionHash: string
success: boolean
}
/**
* Base class for all Smart Wallet ERC-4337 Clients to implement.
* Subclass should inherit 5 methods to support a specific wallet contract:
*
* - getAccountInitCode - return the value to put into the "initCode" field, if the account is not yet deployed. should create the account instance using a factory contract.
* - getNonce - return current account's nonce value
* - encodeExecute - encode the call from entryPoint through our account to the target contract.
* - signUserOpHash - sign the hash of a UserOp.
*
* The user can use the following APIs:
* - createUnsignedUserOp - given "target" and "calldata", fill userOp to perform that operation from the account.
* - createSignedUserOp - helper to call the above createUnsignedUserOp, and then extract the userOpHash and sign it
*/
export abstract class BaseAccountAPI {
private senderAddress!: string
private isPhantom = true
// entryPoint connected to "zero" address. allowed to make static calls (e.g. to getSenderAddress)
private readonly entryPointView: EntryPoint
provider: Provider
overheads?: Partial<GasOverheads>
entryPointAddress: string
accountAddress?: string
paymasterAPI?: PaymasterAPI
/**
* base constructor.
* subclass SHOULD add parameters that define the owner (signer) of this wallet
*/
protected constructor (params: BaseApiParams) {
this.provider = params.provider
this.overheads = params.overheads
this.entryPointAddress = params.entryPointAddress
this.accountAddress = params.accountAddress
this.paymasterAPI = params.paymasterAPI
// factory "connect" define the contract address. the contract "connect" defines the "from" address.
this.entryPointView = EntryPoint__factory.connect(params.entryPointAddress, params.provider).connect(ethers.constants.AddressZero)
}
async init (): Promise<this> {
if (await this.provider.getCode(this.entryPointAddress) === '0x') {
throw new Error(`entryPoint not deployed at ${this.entryPointAddress}`)
}
await this.getAccountAddress()
return this
}
/**
* return the value to put into the "initCode" field, if the contract is not yet deployed.
* this value holds the "factory" address, followed by this account's information
*/
abstract getAccountInitCode (): Promise<string>
/**
* return current account's nonce.
*/
abstract getNonce (): Promise<BigNumber>
/**
* encode the call from entryPoint through our account to the target contract.
* @param target
* @param value
* @param data
*/
abstract encodeExecute (target: string, value: BigNumberish, data: string): Promise<string>
/**
* sign a userOp's hash (userOpHash).
* @param userOpHash
*/
abstract signUserOpHash (userOpHash: string): Promise<string>
/**
* check if the contract is already deployed.
*/
async checkAccountPhantom (): Promise<boolean> {
if (!this.isPhantom) {
// already deployed. no need to check anymore.
return this.isPhantom
}
const senderAddressCode = await this.provider.getCode(this.getAccountAddress())
if (senderAddressCode.length > 2) {
// console.log(`SimpleAccount Contract already deployed at ${this.senderAddress}`)
this.isPhantom = false
} else {
// console.log(`SimpleAccount Contract is NOT YET deployed at ${this.senderAddress} - working in "phantom account" mode.`)
}
return this.isPhantom
}
/**
* calculate the account address even before it is deployed
*/
async getCounterFactualAddress (): Promise<string> {
const initCode = this.getAccountInitCode()
// use entryPoint to query account address (factory can provide a helper method to do the same, but
// this method attempts to be generic
try {
await this.entryPointView.callStatic.getSenderAddress(initCode)
} catch (e: any) {
return e.errorArgs.sender
}
throw new Error('must handle revert')
}
/**
* return initCode value to into the UserOp.
* (either deployment code, or empty hex if contract already deployed)
*/
async getInitCode (): Promise<string> {
if (await this.checkAccountPhantom()) {
return await this.getAccountInitCode()
}
return '0x'
}
/**
* return maximum gas used for verification.
* NOTE: createUnsignedUserOp will add to this value the cost of creation, if the contract is not yet created.
*/
async getVerificationGasLimit (): Promise<BigNumberish> {
return 100000
}
/**
* should cover cost of putting calldata on-chain, and some overhead.
* actual overhead depends on the expected bundle size
*/
async getPreVerificationGas (userOp: Partial<UserOperationStruct>): Promise<number> {
const p = await resolveProperties(userOp)
return calcPreVerificationGas(p, this.overheads)
}
/**
* ABI-encode a user operation. used for calldata cost estimation
*/
packUserOp (userOp: NotPromise<UserOperationStruct>): string {
return packUserOp(userOp, false)
}
async encodeUserOpCallDataAndGasLimit (detailsForUserOp: TransactionDetailsForUserOp): Promise<{ callData: string, callGasLimit: BigNumber }> {
function parseNumber (a: any): BigNumber | null {
if (a == null || a === '') return null
return BigNumber.from(a.toString())
}
const value = parseNumber(detailsForUserOp.value) ?? BigNumber.from(0)
const callData = await this.encodeExecute(detailsForUserOp.target, value, detailsForUserOp.data)
const callGasLimit = parseNumber(detailsForUserOp.gasLimit) ?? await this.provider.estimateGas({
from: this.entryPointAddress,
to: this.getAccountAddress(),
data: callData
})
return {
callData,
callGasLimit
}
}
/**
* return userOpHash for signing.
* This value matches entryPoint.getUserOpHash (calculated off-chain, to avoid a view call)
* @param userOp userOperation, (signature field ignored)
*/
async getUserOpHash (userOp: UserOperationStruct): Promise<string> {
const op = await resolveProperties(userOp)
const chainId = await this.provider.getNetwork().then(net => net.chainId)
return getUserOpHash(op, this.entryPointAddress, chainId)
}
/**
* return the account's address.
* this value is valid even before deploying the contract.
*/
async getAccountAddress (): Promise<string> {
if (this.senderAddress == null) {
if (this.accountAddress != null) {
this.senderAddress = this.accountAddress
} else {
this.senderAddress = await this.getCounterFactualAddress()
}
}
return this.senderAddress
}
async estimateCreationGas (initCode?: string): Promise<BigNumberish> {
if (initCode == null || initCode === '0x') return 0
const deployerAddress = initCode.substring(0, 42)
const deployerCallData = '0x' + initCode.substring(42)
return await this.provider.estimateGas({ to: deployerAddress, data: deployerCallData })
}
/**
* create a UserOperation, filling all details (except signature)
* - if account is not yet created, add initCode to deploy it.
* - if gas or nonce are missing, read them from the chain (note that we can't fill gaslimit before the account is created)
* @param info
*/
async createUnsignedUserOp (info: TransactionDetailsForUserOp): Promise<UserOperationStruct> {
const {
callData,
callGasLimit
} = await this.encodeUserOpCallDataAndGasLimit(info)
const initCode = await this.getInitCode()
const initGas = await this.estimateCreationGas(initCode)
const verificationGasLimit = BigNumber.from(await this.getVerificationGasLimit())
.add(initGas)
let {
maxFeePerGas,
maxPriorityFeePerGas
} = info
if (maxFeePerGas == null || maxPriorityFeePerGas == null) {
const feeData = await this.provider.getFeeData()
if (maxFeePerGas == null) {
maxFeePerGas = feeData.maxFeePerGas ?? undefined
}
if (maxPriorityFeePerGas == null) {
maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? undefined
}
}
const partialUserOp: any = {
sender: this.getAccountAddress(),
nonce: this.getNonce(),
initCode,
callData,
callGasLimit,
verificationGasLimit,
maxFeePerGas,
maxPriorityFeePerGas,
paymasterAndData: '0x'
}
let paymasterAndData: string | undefined
if (this.paymasterAPI != null) {
// fill (partial) preVerificationGas (all except the cost of the generated paymasterAndData)
const userOpForPm = {
...partialUserOp,
preVerificationGas: await this.getPreVerificationGas(partialUserOp)
}
paymasterAndData = await this.paymasterAPI.getPaymasterAndData(userOpForPm)
}
partialUserOp.paymasterAndData = paymasterAndData ?? '0x'
return {
...partialUserOp,
preVerificationGas: this.getPreVerificationGas(partialUserOp),
signature: ''
}
}
/**
* Sign the filled userOp.
* @param userOp the UserOperation to sign (with signature field ignored)
*/
async signUserOp (userOp: UserOperationStruct): Promise<UserOperationStruct> {
const userOpHash = await this.getUserOpHash(userOp)
const signature = this.signUserOpHash(userOpHash)
return {
...userOp,
signature
}
}
/**
* helper method: create and sign a user operation.
* @param info transaction details for the userOp
*/
async createSignedUserOp (info: TransactionDetailsForUserOp): Promise<UserOperationStruct> {
return await this.signUserOp(await this.createUnsignedUserOp(info))
}
/**
* get the transaction that has this userOpHash mined, or null if not found
* @param userOpHash returned by sendUserOpToBundler (or by getUserOpHash..)
* @param timeout stop waiting after this timeout
* @param interval time to wait between polls.
* @return the transactionHash this userOp was mined, or null if not found.
*/
async getUserOpReceipt (userOpHash: string, timeout = 30000, interval = 5000): Promise<string | null> {
const endtime = Date.now() + timeout
while (Date.now() < endtime) {
const events = await this.entryPointView.queryFilter(this.entryPointView.filters.UserOperationEvent(userOpHash))
if (events.length > 0) {
return events[0].transactionHash
}
await new Promise(resolve => setTimeout(resolve, interval))
}
return null
}
}