@portal-hq/provider 4.1.2 → 4.1.4

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,414 @@
1
+ import { PortalCurve } from '@portal-hq/core'
2
+ import { FeatureFlags } from '@portal-hq/core/types'
3
+ import {
4
+ HttpRequester,
5
+ IPortalProvider,
6
+ KeychainAdapter,
7
+ type SigningRequestArguments,
8
+ getClientPlatformVersion,
9
+ } from '@portal-hq/utils'
10
+ import UUID from 'react-native-uuid'
11
+
12
+ import {
13
+ type MpcSignerOptions,
14
+ PortalMobileMpcMetadata,
15
+ type EnclaveSignRequest,
16
+ type EnclaveSignResponse,
17
+ type EnclaveSignResult,
18
+ } from '../../types'
19
+ import Signer from './abstract'
20
+
21
+ enum Operation {
22
+ SIGN = 'sign',
23
+ RAW_SIGN = 'raw_sign',
24
+ }
25
+
26
+ class EnclaveSigner implements Signer {
27
+ private featureFlags: FeatureFlags
28
+ private keychain: KeychainAdapter
29
+ private enclaveMPCHost: string
30
+ private version = 'v6'
31
+ private portalApi?: HttpRequester
32
+ private requests: HttpRequester
33
+
34
+ constructor({
35
+ keychain,
36
+ enclaveMPCHost = 'mpc-client.portalhq.io',
37
+ version = 'v6',
38
+ portalApi,
39
+ featureFlags = {},
40
+ }: MpcSignerOptions & { enclaveMPCHost?: string }) {
41
+ this.featureFlags = featureFlags
42
+ this.keychain = keychain
43
+ this.enclaveMPCHost = enclaveMPCHost
44
+ this.version = version
45
+ this.portalApi = portalApi
46
+ this.requests = new HttpRequester({
47
+ baseUrl: `https://${this.enclaveMPCHost}`,
48
+ })
49
+ }
50
+
51
+ public async sign(
52
+ message: SigningRequestArguments,
53
+ provider: IPortalProvider,
54
+ ): Promise<any> {
55
+ // Always track metrics, but only send if feature flag is enabled
56
+ const shouldSendMetrics =
57
+ this.featureFlags.enableSdkPerformanceMetrics === true
58
+ const signStartTime = performance.now()
59
+ const preOperationStartTime = performance.now()
60
+
61
+ // Generate a traceId for this operation
62
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
63
+ const traceId = (UUID as any).v4() as string
64
+ const metrics: Record<string, number | string | boolean> = {
65
+ hasError: false,
66
+ operation: Operation.SIGN,
67
+ signingMethod: message.method,
68
+ traceId,
69
+ }
70
+
71
+ try {
72
+ const eip155Address = await this.keychain.getEip155Address()
73
+ const apiKey = provider.apiKey
74
+ const { method, chainId, curve, isRaw } = message
75
+
76
+ // Add chainId to metrics
77
+ if (chainId) {
78
+ metrics.chainId = chainId
79
+ }
80
+
81
+ switch (method) {
82
+ case 'eth_requestAccounts':
83
+ return [eip155Address]
84
+ case 'eth_accounts':
85
+ return [eip155Address]
86
+ default:
87
+ break
88
+ }
89
+
90
+ const shares = await this.keychain.getShares()
91
+ let signingShare = shares.secp256k1.share
92
+
93
+ if (curve === PortalCurve.ED25519) {
94
+ if (!shares.ed25519) {
95
+ throw new Error(
96
+ '[Portal.Provider.EnclaveSigner] The ED25519 share is missing from the keychain.',
97
+ )
98
+ }
99
+ signingShare = shares.ed25519.share
100
+ }
101
+
102
+ const metadata: PortalMobileMpcMetadata = {
103
+ clientPlatform: 'REACT_NATIVE',
104
+ clientPlatformVersion: getClientPlatformVersion(),
105
+ isMultiBackupEnabled: this.featureFlags.isMultiBackupEnabled,
106
+ mpcServerVersion: this.version,
107
+ optimized: true,
108
+ curve,
109
+ chainId,
110
+ isRaw,
111
+ reqId: traceId,
112
+ connectionTracingEnabled: shouldSendMetrics,
113
+ }
114
+
115
+ let formattedParams: string
116
+ let rpcUrl: string
117
+
118
+ if (isRaw) {
119
+ formattedParams = JSON.stringify(
120
+ this.buildParams(method, message.params),
121
+ )
122
+ rpcUrl = ''
123
+ metrics.operation = Operation.RAW_SIGN
124
+ } else {
125
+ formattedParams = JSON.stringify(
126
+ this.buildParams(method, message.params),
127
+ )
128
+ rpcUrl = provider.getGatewayUrl(chainId)
129
+ }
130
+
131
+ if (typeof formattedParams !== 'string') {
132
+ throw new Error(
133
+ `[Portal.Provider.EnclaveSigner] The formatted params for the signing request could not be converted to a string. The params were: ${formattedParams}`,
134
+ )
135
+ }
136
+
137
+ // Record pre-operation time
138
+ metrics.sdkPreOperationMs = performance.now() - preOperationStartTime
139
+
140
+ // Measure enclave signing operation time
141
+ const enclaveSignStartTime = performance.now()
142
+
143
+ const result = isRaw
144
+ ? await this.enclaveRawSign(
145
+ apiKey,
146
+ JSON.stringify(signingShare),
147
+ formattedParams,
148
+ curve || 'SECP256K1',
149
+ )
150
+ : await this.enclaveSign(
151
+ apiKey,
152
+ JSON.stringify(signingShare),
153
+ message.method,
154
+ formattedParams,
155
+ rpcUrl,
156
+ chainId || '',
157
+ JSON.stringify(metadata),
158
+ )
159
+
160
+ // Post-operation processing time starts
161
+ const postOperationStartTime = performance.now()
162
+
163
+ // Record HTTP call time
164
+ metrics.enclaveHttpCallMs = performance.now() - enclaveSignStartTime
165
+
166
+ // Record post-operation time
167
+ metrics.sdkPostOperationMs = performance.now() - postOperationStartTime
168
+
169
+ // Calculate total SDK signing time
170
+ metrics.sdkOperationMs = performance.now() - signStartTime
171
+
172
+ // Log performance timing to console
173
+ const timingMetrics: Record<string, number> = {}
174
+ if (typeof metrics.sdkPreOperationMs === 'number') {
175
+ timingMetrics['Pre-operation'] = metrics.sdkPreOperationMs
176
+ }
177
+ if (typeof metrics.enclaveHttpCallMs === 'number') {
178
+ timingMetrics['Enclave HTTP Call'] = metrics.enclaveHttpCallMs
179
+ }
180
+ if (typeof metrics.sdkPostOperationMs === 'number') {
181
+ timingMetrics['Post-operation'] = metrics.sdkPostOperationMs
182
+ }
183
+
184
+ // Only send metrics if the feature flag is enabled
185
+ if (shouldSendMetrics && this.portalApi) {
186
+ try {
187
+ await this.sendMetrics(metrics, apiKey)
188
+ } catch (err) {
189
+ // No-op
190
+ }
191
+ }
192
+
193
+ return result
194
+ } catch (error) {
195
+ // Calculate total time even in error case
196
+ metrics.sdkOperationMs = performance.now() - signStartTime
197
+
198
+ // Log performance timing to console even in error case
199
+ const errorTimingMetrics: Record<string, number> = {}
200
+ if (typeof metrics.sdkPreOperationMs === 'number') {
201
+ errorTimingMetrics['Pre-operation'] = metrics.sdkPreOperationMs
202
+ }
203
+
204
+ // Only send metrics if the feature flag is enabled
205
+ if (shouldSendMetrics) {
206
+ const apiKey = provider.apiKey
207
+ metrics.hasError = true
208
+ try {
209
+ await this.sendMetrics(metrics, apiKey)
210
+ } catch {
211
+ // No-op
212
+ }
213
+ }
214
+
215
+ throw error
216
+ }
217
+ }
218
+
219
+ private async enclaveRawSign(
220
+ apiKey: string,
221
+ signingShare: string,
222
+ params: string,
223
+ curve: string,
224
+ ): Promise<string> {
225
+ if (!apiKey || !signingShare || !params || !curve) {
226
+ return this.encodeErrorResult(
227
+ 'INVALID_PARAMETERS',
228
+ 'Invalid parameters provided for raw signing',
229
+ )
230
+ }
231
+
232
+ const requestBody = {
233
+ params: params,
234
+ share: signingShare,
235
+ }
236
+
237
+ const endpoint = `/v1/raw/sign/${curve}`
238
+
239
+ try {
240
+ const response = await this.requests.post<EnclaveSignResponse>(endpoint, {
241
+ headers: {
242
+ Authorization: `Bearer ${apiKey}`,
243
+ 'Content-Type': 'application/json',
244
+ },
245
+ body: requestBody,
246
+ })
247
+
248
+ return this.encodeSuccessResult(response.data)
249
+ } catch (error: any) {
250
+ if (error?.response?.data) {
251
+ const portalError = this.decodePortalError(
252
+ JSON.stringify(error.response.data),
253
+ )
254
+ return this.encodeErrorResult(portalError?.id, portalError?.message)
255
+ }
256
+ return this.encodeErrorResult(
257
+ 'SIGNING_NETWORK_ERROR',
258
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument
259
+ error.message || 'Network error occurred',
260
+ )
261
+ }
262
+ }
263
+
264
+ private async enclaveSign(
265
+ apiKey: string,
266
+ signingShare: string,
267
+ method: string,
268
+ params: string,
269
+ rpcURL: string,
270
+ chainId: string,
271
+ metadata: string,
272
+ ): Promise<string> {
273
+ if (
274
+ !apiKey ||
275
+ !signingShare ||
276
+ !method ||
277
+ !params ||
278
+ !chainId ||
279
+ !metadata
280
+ ) {
281
+ return this.encodeErrorResult(
282
+ 'INVALID_PARAMETERS',
283
+ 'Invalid parameters provided',
284
+ )
285
+ }
286
+
287
+ const requestBody: EnclaveSignRequest = {
288
+ method: method,
289
+ params: params,
290
+ share: signingShare,
291
+ chainId: chainId,
292
+ rpcUrl: rpcURL,
293
+ metadataStr: metadata,
294
+ clientPlatform: 'REACT_NATIVE',
295
+ clientPlatformVersion: getClientPlatformVersion(),
296
+ }
297
+
298
+ try {
299
+ const response = await this.requests.post<EnclaveSignResponse>(
300
+ '/v1/sign',
301
+ {
302
+ headers: {
303
+ Authorization: `Bearer ${apiKey}`,
304
+ 'Content-Type': 'application/json',
305
+ },
306
+ body: requestBody,
307
+ },
308
+ )
309
+
310
+ return this.encodeSuccessResult(response.data)
311
+ } catch (error: any) {
312
+ if (error?.response?.data) {
313
+ const portalError = this.decodePortalError(
314
+ JSON.stringify(error.response.data),
315
+ )
316
+ return this.encodeErrorResult(portalError?.id, portalError?.message)
317
+ }
318
+ return this.encodeErrorResult(
319
+ 'SIGNING_NETWORK_ERROR',
320
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument
321
+ error.message || 'Network error occurred',
322
+ )
323
+ }
324
+ }
325
+
326
+ // Helper function to encode success results
327
+ private encodeSuccessResult(data: string): string {
328
+ const successResult: EnclaveSignResult = { data: data, error: undefined }
329
+ return this.encodeJSON(successResult)
330
+ }
331
+
332
+ // Helper function to decode Portal errors
333
+ private decodePortalError(
334
+ errorStr?: string,
335
+ ): { id?: string; message?: string } | null {
336
+ if (!errorStr) return null
337
+ try {
338
+ return JSON.parse(errorStr)
339
+ } catch {
340
+ return null
341
+ }
342
+ }
343
+
344
+ // Helper function to encode error results
345
+ private encodeErrorResult(id?: string, message?: string): string {
346
+ const errorResult: EnclaveSignResult = {
347
+ data: undefined,
348
+ error: { id, message },
349
+ }
350
+ return this.encodeJSON(errorResult)
351
+ }
352
+
353
+ // Helper function to encode any object to JSON string
354
+ private encodeJSON<T>(value: T): string {
355
+ try {
356
+ const jsonString = JSON.stringify(value)
357
+ return jsonString
358
+ } catch (error: any) {
359
+ return JSON.stringify({
360
+ error: {
361
+ id: 'ENCODING_ERROR',
362
+ message: `Failed to encode JSON: ${error.message}`,
363
+ },
364
+ })
365
+ }
366
+ }
367
+
368
+ private async sendMetrics(
369
+ metrics: Record<string, number | string | boolean>,
370
+ apiKey: string,
371
+ ): Promise<void> {
372
+ try {
373
+ if (this.portalApi) {
374
+ await this.portalApi.post('/api/v3/clients/me/sdk/metrics', {
375
+ headers: {
376
+ Authorization: `Bearer ${apiKey}`,
377
+ 'Content-Type': 'application/json',
378
+ },
379
+ body: metrics,
380
+ })
381
+ }
382
+ } catch {
383
+ // No-op
384
+ }
385
+ }
386
+
387
+ private buildParams = (method: string, txParams: any) => {
388
+ let params = txParams
389
+
390
+ switch (method) {
391
+ case 'eth_sign':
392
+ case 'personal_sign':
393
+ case 'eth_signTypedData_v3':
394
+ case 'eth_signTypedData_v4':
395
+ case 'sol_signMessage':
396
+ case 'sol_signTransaction':
397
+ case 'sol_signAndSendTransaction':
398
+ case 'sol_signAndConfirmTransaction':
399
+ if (!Array.isArray(txParams)) {
400
+ params = [txParams]
401
+ }
402
+ break
403
+ default:
404
+ if (Array.isArray(txParams)) {
405
+ if (txParams.length === 1) {
406
+ params = txParams[0]
407
+ }
408
+ }
409
+ }
410
+ return params
411
+ }
412
+ }
413
+
414
+ export default EnclaveSigner
@@ -1,2 +1,3 @@
1
1
  export { default as Signer } from './abstract'
2
2
  export { default as MpcSigner } from './mpc'
3
+ export { default as EnclaveSigner } from './enclave'
@@ -9,6 +9,7 @@ import {
9
9
  getClientPlatformVersion,
10
10
  } from '@portal-hq/utils'
11
11
  import { NativeModules } from 'react-native'
12
+ import UUID from 'react-native-uuid'
12
13
 
13
14
  import {
14
15
  type MpcSignerOptions,
@@ -61,9 +62,14 @@ class MpcSigner implements Signer {
61
62
  this.featureFlags.enableSdkPerformanceMetrics === true
62
63
  const signStartTime = performance.now()
63
64
  const preOperationStartTime = performance.now()
65
+ // Generate a traceId for this operation
66
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
67
+ const traceId = (UUID as any).v4() as string
64
68
  const metrics: Record<string, number | string | boolean> = {
65
- operation: Operation.SIGN,
66
69
  hasError: false,
70
+ operation: Operation.SIGN,
71
+ signingMethod: message.method,
72
+ traceId,
67
73
  }
68
74
  try {
69
75
  const eip155Address = await this.keychain.getEip155Address()
@@ -107,6 +113,8 @@ class MpcSigner implements Signer {
107
113
  curve,
108
114
  chainId,
109
115
  isRaw,
116
+ reqId: traceId,
117
+ connectionTracingEnabled: shouldSendMetrics,
110
118
  }
111
119
 
112
120
  const stringifiedMetadata = JSON.stringify(metadata)
@@ -160,15 +168,28 @@ class MpcSigner implements Signer {
160
168
 
161
169
  // Add binary metrics to our metrics object
162
170
  if (meta?.metrics) {
163
- if (meta.metrics.wsConnectDurationMs) {
164
- metrics.sdkBinaryWSConnectMs = meta.metrics.wsConnectDurationMs
171
+ const binaryMetrics = meta.metrics
172
+ if (binaryMetrics.wsConnectDurationMs) {
173
+ metrics.sdkBinaryWSConnectMs = binaryMetrics.wsConnectDurationMs
174
+ }
175
+ if (binaryMetrics.operationDurationMs) {
176
+ metrics.sdkBinaryOperationMs = binaryMetrics.operationDurationMs
177
+ }
178
+ if (binaryMetrics.tlsHandshakeMs) {
179
+ metrics.sdkBinaryTlsHandshakeMs = binaryMetrics.tlsHandshakeMs
165
180
  }
166
- if (meta.metrics.operationDurationMs) {
167
- metrics.sdkBinaryOperationMs = meta.metrics.operationDurationMs
181
+ if (binaryMetrics.firstResponseMs) {
182
+ metrics.sdkBinaryFirstResponseMs = binaryMetrics.firstResponseMs
183
+ }
184
+ if (binaryMetrics.dnsLookupMs) {
185
+ metrics.sdkBinaryDnsLookupMs = binaryMetrics.dnsLookupMs
186
+ }
187
+ if (binaryMetrics.connectMs) {
188
+ metrics.sdkBinaryConnectMs = binaryMetrics.connectMs
168
189
  }
169
190
  }
170
191
 
171
- if (error?.code > 0) {
192
+ if (error?.id) {
172
193
  throw new PortalMpcError(error)
173
194
  }
174
195
 
@@ -182,7 +203,7 @@ class MpcSigner implements Signer {
182
203
  if (shouldSendMetrics && this.portalApi) {
183
204
  try {
184
205
  await this.sendMetrics(metrics, apiKey)
185
- } catch {
206
+ } catch (err) {
186
207
  // No-op
187
208
  }
188
209
  }
package/types.d.ts CHANGED
@@ -25,6 +25,7 @@ export interface MpcSignerOptions extends SignerOptions {
25
25
  isSimulator?: boolean
26
26
  mpcHost?: string
27
27
  portalApi?: HttpRequester
28
+ enclaveMPCHost?: string
28
29
  version?: string
29
30
  featureFlags?: FeatureFlags
30
31
  }
@@ -45,6 +46,8 @@ export interface PortalMobileMpcMetadata {
45
46
  mpcServerVersion: string
46
47
  optimized: true
47
48
  isRaw?: boolean
49
+ reqId?: string
50
+ connectionTracingEnabled?: boolean
48
51
  }
49
52
 
50
53
  export interface PortalMobileMpc {
@@ -84,6 +87,7 @@ export interface ProviderOptions {
84
87
  autoApprove?: boolean
85
88
  apiHost?: string
86
89
  mpcHost?: string
90
+ enclaveMPCHost?: string
87
91
  version?: string
88
92
  featureFlags?: FeatureFlags
89
93
  }
@@ -104,6 +108,30 @@ export interface SignResult {
104
108
  S: string
105
109
  }
106
110
 
111
+ export interface EnclaveSignResult {
112
+ data?: string
113
+ error?: {
114
+ id?: string
115
+ message?: string
116
+ }
117
+ }
118
+
119
+ export interface ProviderOptions {
120
+ // Required
121
+ apiKey: string
122
+ keychain: KeychainAdapter
123
+ gatewayConfig: GatewayLike
124
+
125
+ // Optional
126
+ autoApprove?: boolean
127
+ apiHost?: string
128
+ mpcHost?: string
129
+ enclaveMPCHost?: string
130
+ version?: string
131
+ chainId?: string
132
+ featureFlags?: FeatureFlags
133
+ }
134
+
107
135
  export interface SignerOptions {}
108
136
 
109
137
  export interface Eip1559 {
@@ -128,6 +156,10 @@ export interface SigningResponse {
128
156
  metrics?: {
129
157
  wsConnectDurationMs?: number
130
158
  operationDurationMs?: number
159
+ tlsHandshakeMs?: number
160
+ firstResponseMs?: number
161
+ dnsLookupMs?: number
162
+ connectMs?: number
131
163
  }
132
164
  }
133
165
  }
@@ -135,3 +167,22 @@ export interface SigningResponse {
135
167
  export interface SwitchEthereumChainParameter {
136
168
  chainId: number
137
169
  }
170
+
171
+ export interface EnclaveSignRequest {
172
+ method: string
173
+ params: string
174
+ share: string
175
+ chainId: string
176
+ rpcUrl: string
177
+ metadataStr: string
178
+ clientPlatform: string
179
+ clientPlatformVersion: string
180
+ }
181
+
182
+ export interface EnclaveSignResponse {
183
+ data: string
184
+ error?: {
185
+ id: string
186
+ message: string
187
+ }
188
+ }