@portal-hq/provider 0.2.0-beta10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,494 @@
1
+ import {
2
+ InvalidApiKeyError,
3
+ InvalidChainIdError,
4
+ InvalidGatewayConfigError,
5
+ KeychainAdapter,
6
+ ProviderRpcError,
7
+ RpcErrorCodes,
8
+ } from '@portal-hq/utils'
9
+
10
+ import { HttpRequester } from '../requesters'
11
+ import { HttpSigner, MpcSigner, Signer } from '../signers'
12
+ import {
13
+ type EventHandler,
14
+ type GatewayLike,
15
+ type ProviderOptions,
16
+ type RegisteredEventHandler,
17
+ type RequestArguments,
18
+ type SwitchEthereumChainParameter,
19
+ } from '../../types'
20
+
21
+ const passiveSignerMethods = [
22
+ 'eth_accounts',
23
+ 'eth_chainId',
24
+ 'eth_requestAccounts',
25
+ ]
26
+
27
+ const signerMethods = [
28
+ 'eth_accounts',
29
+ 'eth_chainId',
30
+ 'eth_requestAccounts',
31
+ 'eth_sendTransaction',
32
+ 'eth_sign',
33
+ 'eth_signTransaction',
34
+ 'eth_signTypedData',
35
+ 'personal_sign',
36
+ ]
37
+
38
+ class Provider {
39
+ public readonly mpcUrl: string
40
+ public readonly portal: HttpRequester
41
+ public apiKey: string
42
+
43
+ public chainId: number
44
+ public isMPC?: boolean
45
+
46
+ private _address: string
47
+ private apiUrl: string
48
+ private autoApprove?: boolean
49
+ private events: Record<string, RegisteredEventHandler[]>
50
+ private keychain: KeychainAdapter
51
+ private log: Console
52
+ private gatewayConfig: GatewayLike
53
+ private rpc: HttpRequester
54
+
55
+ public signer?: Signer
56
+
57
+ constructor({
58
+ // Required options
59
+ apiKey,
60
+ chainId,
61
+ keychain,
62
+
63
+ // Optional options
64
+ apiUrl = 'api.portalhq.io',
65
+ autoApprove = false,
66
+ enableMpc = true,
67
+ mpcUrl = 'mpc.portalhq.io',
68
+ gatewayConfig = {},
69
+ }: ProviderOptions) {
70
+ // Handle required fields
71
+ if (!apiKey || apiKey.length === 0) {
72
+ throw new InvalidApiKeyError()
73
+ }
74
+ if (!chainId) {
75
+ throw new InvalidChainIdError()
76
+ }
77
+ if (!gatewayConfig) {
78
+ throw new InvalidGatewayConfigError()
79
+ }
80
+
81
+ // Handle the stuff we can auto-set
82
+ this.apiKey = apiKey
83
+ this.apiUrl = apiUrl
84
+ this.autoApprove = autoApprove
85
+ this.chainId = chainId
86
+ this.events = {}
87
+ this.isMPC = enableMpc
88
+ this.keychain = keychain
89
+ this.log = console
90
+ this.mpcUrl = mpcUrl
91
+ this.portal = new HttpRequester({
92
+ baseUrl: this.apiUrl,
93
+ })
94
+
95
+ // Handle RPC Initialization
96
+ this.gatewayConfig = gatewayConfig
97
+
98
+ this.rpc = new HttpRequester({
99
+ baseUrl: this.getRpcUrl(),
100
+ })
101
+
102
+ if (this.isMPC) {
103
+ // If MPC is enabled, initialize an MpcSigner
104
+ this.signer = new MpcSigner({
105
+ mpcUrl: this.mpcUrl,
106
+ keychain: this.keychain,
107
+ })
108
+ } else {
109
+ // If MPC is disabled, initialize an HttpSigner, talking to whatever httpHost was provided
110
+ this.signer = new HttpSigner({
111
+ portal: this.portal,
112
+ })
113
+ }
114
+
115
+ this.dispatchConnect()
116
+ }
117
+
118
+ get address(): string {
119
+ return this._address
120
+ }
121
+
122
+ set address(value: string) {
123
+ this._address = value
124
+
125
+ if (this.signer && this.isMPC) {
126
+ ;(this.signer as MpcSigner)._address = value
127
+ }
128
+ }
129
+
130
+ get rpcUrl(): string {
131
+ return this.getRpcUrl()
132
+ }
133
+
134
+ /**
135
+ * Invokes all registered event handlers with the data provided
136
+ * - If any `once` handlers exist, they are removed after all handlers are invoked
137
+ *
138
+ * @param event The name of the event to be handled
139
+ * @param data The data to be passed to registered event handlers
140
+ * @returns BaseProvider
141
+ */
142
+ public emit(event: string, data: any): Provider {
143
+ // Grab the registered event handlers if any are available
144
+ const handlers = this.events[event] || []
145
+
146
+ // Execute every event handler
147
+ for (const registeredEventHandler of handlers) {
148
+ registeredEventHandler.handler(data)
149
+ }
150
+
151
+ // Remove any registered event handlers with the `once` flag
152
+ this.events[event] = handlers.filter((handler) => !handler.once)
153
+
154
+ return this
155
+ }
156
+
157
+ /**
158
+ * Registers an event handler for the provided event
159
+ *
160
+ * @param event The event name to add a handler to
161
+ * @param callback The callback to be invoked when the event is emitted
162
+ * @returns BaseProvider
163
+ */
164
+ public on(event: string, callback: EventHandler): Provider {
165
+ // If no handlers are registered for this event, create an entry for the event
166
+ if (!this.events[event]) {
167
+ this.events[event] = []
168
+ }
169
+
170
+ // Register event handler with the rudimentary event bus
171
+ if (typeof callback !== 'undefined') {
172
+ this.events[event].push({
173
+ handler: callback,
174
+ once: false,
175
+ })
176
+ }
177
+
178
+ return this
179
+ }
180
+
181
+ /**
182
+ * Registers a single-execution event handler for the provided event
183
+ *
184
+ * @param event The event name to add a handler to
185
+ * @param callback The callback to be invoked the next time the event is emitted
186
+ * @returns BaseProvider
187
+ */
188
+ public once(event: string, callback: EventHandler): Provider {
189
+ // If no handlers are registered for this event, create an entry for the event
190
+ if (!this.events[event]) {
191
+ this.events[event] = []
192
+ }
193
+
194
+ // Register event handler with the rudimentary event bus
195
+ if (typeof callback !== 'undefined') {
196
+ this.events[event].push({
197
+ handler: callback,
198
+ once: true,
199
+ })
200
+ }
201
+
202
+ return this
203
+ }
204
+
205
+ public removeEventListener(
206
+ event: string,
207
+ listenerToRemove?: EventHandler,
208
+ ): void {
209
+ if (!this.events[event]) {
210
+ this.log.info(
211
+ `[PortalProvider] Attempted to remove a listener from unregistered event '${event}'. Ignoring.`,
212
+ )
213
+ return
214
+ }
215
+
216
+ if (!listenerToRemove) {
217
+ this.events[event] = []
218
+ } else {
219
+ const filterEventHandlers = (
220
+ registeredEventHandler: RegisteredEventHandler,
221
+ ) => {
222
+ return registeredEventHandler.handler !== listenerToRemove
223
+ }
224
+
225
+ this.events[event] = this.events[event].filter(filterEventHandlers)
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Handles request routing in compliance with the EIP-1193 Ethereum Javascript Provider API
231
+ * - See here for more info: https://eips.ethereum.org/EIPS/eip-1193
232
+ *
233
+ * @param args The arguments of the request being made
234
+ * @returns Promise<any>
235
+ */
236
+ public async request({ method, params }: RequestArguments): Promise<any> {
237
+ if (method === 'eth_chainId') {
238
+ return this.chainId
239
+ }
240
+
241
+ const isSignerMethod = signerMethods.includes(method)
242
+
243
+ let result: any
244
+ if (!isSignerMethod && !method.startsWith('wallet_')) {
245
+ // Send to Gateway for RPC calls
246
+ const response = await this.handleGatewayRequests({ method, params })
247
+
248
+ this.emit('portal_signatureReceived', {
249
+ method,
250
+ params,
251
+ signature: response,
252
+ })
253
+
254
+ if (response.error) {
255
+ throw new ProviderRpcError(response.error)
256
+ }
257
+
258
+ result = response.result
259
+ } else if (isSignerMethod) {
260
+ // Handle signing
261
+ const transactionHash = await this.handleSigningRequests({
262
+ method,
263
+ params,
264
+ })
265
+
266
+ if (transactionHash) {
267
+ console.log(`Received transaction hash: `, transactionHash)
268
+
269
+ this.emit('portal_signatureReceived', {
270
+ method,
271
+ params,
272
+ signature: transactionHash,
273
+ })
274
+
275
+ result = transactionHash
276
+ }
277
+ } else {
278
+ // Unsupported method
279
+ throw new ProviderRpcError({
280
+ code: RpcErrorCodes.UnsupportedMethod,
281
+ data: {
282
+ method,
283
+ params,
284
+ },
285
+ })
286
+ }
287
+
288
+ return result
289
+ }
290
+
291
+ /**
292
+ * Updates the chainId of this instance and builds a new RPC HttpRequester for
293
+ * the gateway used for the new chain
294
+ *
295
+ * @param chainId A hex string of the chainId to use for this connection
296
+ * @returns BaseProvider
297
+ */
298
+ public async updateChainId(chainId: string): Promise<Provider> {
299
+ this.chainId = Number(`${chainId}`)
300
+
301
+ this.rpc = new HttpRequester({
302
+ baseUrl: this.getRpcUrl(),
303
+ })
304
+
305
+ this.emit('chainChanged', { chainId } as SwitchEthereumChainParameter)
306
+
307
+ return this
308
+ }
309
+
310
+ /**
311
+ * Kicks off the approval flow for a given request
312
+ *
313
+ * @param args The arguments of the request being made
314
+ */
315
+ protected async getApproval({
316
+ method,
317
+ params,
318
+ }: RequestArguments): Promise<boolean> {
319
+ // If autoApprove is enabled, just resolve to true
320
+ if (this.autoApprove) {
321
+ return true
322
+ }
323
+
324
+ if (!this.events['portal_signingRequested']) {
325
+ throw new Error(
326
+ `[PortalProvider] Auto-approve is disabled. Cannot perform signing requests without an event handler for the 'portal_signingRequested' event.`,
327
+ )
328
+ }
329
+
330
+ return new Promise((resolve) => {
331
+ // Remove already used listeners
332
+ this.removeEventListener('portal_signingApproved')
333
+ this.removeEventListener('portal_signingRejected')
334
+
335
+ // If the signing has been approved, resolve to true
336
+ this.once(
337
+ 'portal_signingApproved',
338
+ ({ method: approvedMethod, params: approvedParams }) => {
339
+ console.log(`[PortalProvider] Signing Approved`, method, params)
340
+ // Remove already used listeners
341
+ this.removeEventListener('portal_signingApproved')
342
+ this.removeEventListener('portal_signingRejected')
343
+
344
+ // First verify that this is the same signing request
345
+ if (
346
+ method === approvedMethod &&
347
+ JSON.stringify(params) === JSON.stringify(approvedParams)
348
+ ) {
349
+ resolve(true)
350
+ }
351
+ },
352
+ )
353
+
354
+ // If the signing request has been rejected, resolve to false
355
+ this.once(
356
+ 'portal_signingRejected',
357
+ ({ method: rejectedMethod, params: rejectedParams }) => {
358
+ console.log(`[PortalProvider] Signing Approved`, method, params)
359
+ // Remove already used listeners
360
+ this.removeEventListener('portal_signingApproved')
361
+ this.removeEventListener('portal_signingRejected')
362
+
363
+ // First verify that this is the same signing request
364
+ if (
365
+ method === rejectedMethod &&
366
+ JSON.stringify(params) === JSON.stringify(rejectedParams)
367
+ ) {
368
+ resolve(false)
369
+ }
370
+ },
371
+ )
372
+
373
+ // Tell any listening clients that signing has been requested
374
+ this.emit('portal_signingRequested', {
375
+ method,
376
+ params,
377
+ })
378
+ })
379
+ }
380
+
381
+ private async dispatchConnect(): Promise<void> {
382
+ console.log(
383
+ `[PortalProvider] Connected on chainId: 0x${this.chainId.toString(16)}`,
384
+ )
385
+ this.emit('connect', {
386
+ chainId: `0x${this.chainId.toString(16)}`,
387
+ })
388
+ }
389
+
390
+ private async dispatchDisconnect(): Promise<void> {
391
+ this.emit('disconnect', {
392
+ error: new ProviderRpcError({
393
+ code: RpcErrorCodes.Disconnected,
394
+ }),
395
+ })
396
+ }
397
+
398
+ /**
399
+ * Determines the RPC URL to be used for the current chain
400
+ *
401
+ * @returns string
402
+ */
403
+ private getRpcUrl(): string {
404
+ if (typeof this.gatewayConfig === 'string') {
405
+ // If the gatewayConfig is just a static URL, return that
406
+ return this.gatewayConfig
407
+ } else if (
408
+ typeof this.gatewayConfig === 'object' &&
409
+ !this.gatewayConfig.hasOwnProperty(this.chainId)
410
+ ) {
411
+ // If there's no explicit mapping for the current chainId, error out
412
+ throw new Error(
413
+ `[PortalProvider] No RPC endpoint configured for chainId: ${this.chainId}`,
414
+ )
415
+ }
416
+
417
+ // Get the entry for the current chainId from the gatewayConfig
418
+ const config = this.gatewayConfig[this.chainId]
419
+
420
+ if (typeof config === 'string') {
421
+ return config
422
+ }
423
+
424
+ // If we got this far, there's no way to support the chain with the current config
425
+ throw new Error(
426
+ `[PortalProvider] Could not find a valid gatewayConfig entry for chainId: ${this.chainId}`,
427
+ )
428
+ }
429
+
430
+ /**
431
+ * Sends the provided request payload along to the RPC HttpRequester
432
+ *
433
+ * @param args The arguments of the request being made
434
+ * @returns Promise<any>
435
+ */
436
+ private async handleGatewayRequests({
437
+ method,
438
+ params,
439
+ }: RequestArguments): Promise<any> {
440
+ // Pass request off to the gateway
441
+ return await this.rpc.post<any>('', {
442
+ body: {
443
+ jsonrpc: '2.0',
444
+ id: this.chainId,
445
+ method,
446
+ params,
447
+ },
448
+ })
449
+ }
450
+
451
+ /**
452
+ * Sends the provided request payload along to the Signer
453
+ *
454
+ * @param args The arguments of the request being made
455
+ * @returns Promise<any>
456
+ */
457
+ private async handleSigningRequests({
458
+ method,
459
+ params,
460
+ }: RequestArguments): Promise<any> {
461
+ const isApproved = passiveSignerMethods.includes(method)
462
+ ? true
463
+ : await this.getApproval({ method, params })
464
+
465
+ if (!isApproved) {
466
+ this.log.info(
467
+ `[PortalProvider] Request for signing method '${method}' could not be completed because it was not approved by the user.`,
468
+ )
469
+ return
470
+ }
471
+
472
+ switch (method) {
473
+ case 'eth_chainId':
474
+ return `0x${this.chainId.toString(16)}`
475
+ case 'eth_accounts':
476
+ case 'eth_requestAccounts':
477
+ case 'eth_sendTransaction':
478
+ case 'eth_sign':
479
+ case 'eth_signTransaction':
480
+ case 'eth_signTypedData':
481
+ case 'personal_sign':
482
+ return await this.signer?.sign(
483
+ { chainId: this.chainId, method, params },
484
+ this,
485
+ )
486
+ default:
487
+ throw new Error(
488
+ '[PortalProvider] Method "' + method + '" not supported',
489
+ )
490
+ }
491
+ }
492
+ }
493
+
494
+ export default Provider
@@ -0,0 +1,61 @@
1
+ import { HttpRequest } from '@portal-hq/utils'
2
+ import {
3
+ type HttpRequestOptions,
4
+ type HttpOptions,
5
+ type HttpRequesterOptions,
6
+ } from '@portal-hq/utils/types'
7
+
8
+ class HttpRequester {
9
+ private baseUrl: string
10
+
11
+ constructor({ baseUrl }: HttpRequesterOptions) {
12
+ this.baseUrl = baseUrl.startsWith('https://')
13
+ ? baseUrl
14
+ : `https://${baseUrl}`
15
+ }
16
+
17
+ public async get<T>(path: string, options?: HttpOptions): Promise<T> {
18
+ const requestOptions = {
19
+ method: 'GET',
20
+ url: `${this.baseUrl}${path}`,
21
+ } as HttpRequestOptions
22
+
23
+ if (options && options.headers) {
24
+ requestOptions.headers = this.buildHeaders(options.headers)
25
+ }
26
+
27
+ const request = new HttpRequest(requestOptions)
28
+ const response = (await request.send()) as T
29
+
30
+ return response
31
+ }
32
+
33
+ public async post<T>(path: string, options?: HttpOptions): Promise<T> {
34
+ const requestOptions = {
35
+ method: 'POST',
36
+ url: `${this.baseUrl}${path}`,
37
+ } as HttpRequestOptions
38
+
39
+ requestOptions.headers = this.buildHeaders(
40
+ options && options.headers ? options.headers : {},
41
+ )
42
+
43
+ if (options && options.body) {
44
+ requestOptions.body = options.body
45
+ }
46
+
47
+ const request = new HttpRequest(requestOptions)
48
+ const response = (await request.send()) as T
49
+
50
+ return response
51
+ }
52
+
53
+ private buildHeaders(headers: Record<string, any>): Record<string, any> {
54
+ return {
55
+ 'Content-Type': 'application/json',
56
+ ...headers,
57
+ }
58
+ }
59
+ }
60
+
61
+ export default HttpRequester
@@ -0,0 +1 @@
1
+ export { default as HttpRequester } from './http'
@@ -0,0 +1,14 @@
1
+ import { type SigningRequestArguments, type SignResult } from '../../types'
2
+
3
+ abstract class Signer {
4
+ public async sign(
5
+ message: SigningRequestArguments,
6
+ provider?: any,
7
+ ): Promise<SignResult> {
8
+ throw new Error(
9
+ '[Portal] sign() method must be implemented in a child of BaseSigner',
10
+ )
11
+ }
12
+ }
13
+
14
+ export default Signer
@@ -0,0 +1,79 @@
1
+ import { Keychain } from '@portal-hq/keychain'
2
+ import { MissingOptionError } from '@portal-hq/utils'
3
+
4
+ import {
5
+ type HttpSignerOptions,
6
+ type SigningRequestArguments,
7
+ type SignResult,
8
+ } from '../../types'
9
+
10
+ import Signer from './abstract'
11
+ import { Provider } from '../index'
12
+ import HttpRequester from '../requesters/http'
13
+
14
+ class HttpSigner implements Signer {
15
+ private portal: HttpRequester
16
+ private keychain: Keychain
17
+
18
+ constructor(opts: HttpSignerOptions) {
19
+ if (!opts.portal) {
20
+ throw new MissingOptionError({
21
+ className: 'HttpSigner',
22
+ option: 'portal',
23
+ })
24
+ }
25
+
26
+ this.keychain = new Keychain()
27
+ this.portal = opts.portal
28
+ }
29
+
30
+ public async sign(
31
+ message: SigningRequestArguments,
32
+ provider: Provider,
33
+ ): Promise<any> {
34
+ const address = await this.keychain.getAddress()
35
+ const arrayMethods = ['personal_sign']
36
+ const { chainId, method, params } = message
37
+
38
+ switch (method) {
39
+ case 'eth_requestAccounts':
40
+ return [address]
41
+ case 'eth_accounts':
42
+ return [address]
43
+ default:
44
+ break
45
+ }
46
+
47
+ console.log(
48
+ `[Portal:HttpSigner] Requesting signature from exchange for:`,
49
+ JSON.stringify(
50
+ {
51
+ chainId,
52
+ method,
53
+ params: JSON.stringify([params]),
54
+ },
55
+ null,
56
+ 2,
57
+ ),
58
+ )
59
+
60
+ const signatureResponse = await this.portal.post<SignResult>(
61
+ '/api/v1/clients/transactions/sign',
62
+ {
63
+ body: {
64
+ chainId,
65
+ method,
66
+ params: JSON.stringify([params]),
67
+ },
68
+ headers: {
69
+ Authorization: `Bearer ${provider.apiKey}`,
70
+ 'Content-Type': 'application/json',
71
+ },
72
+ },
73
+ )
74
+
75
+ return signatureResponse
76
+ }
77
+ }
78
+
79
+ export default HttpSigner
@@ -0,0 +1,3 @@
1
+ export { default as Signer } from './abstract'
2
+ export { default as HttpSigner } from './http'
3
+ export { default as MpcSigner } from './mpc'