@portal-hq/provider 4.1.3 → 4.1.5

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,421 @@
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
+ // Build params
116
+ // Avoid double JSON encoding: if params is already a string (e.g. a hex message), pass it directly; otherwise stringify objects/arrays.
117
+ const params = this.buildParams(method, message.params)
118
+ let formattedParams: string
119
+
120
+ if (isRaw) {
121
+ if (typeof params !== 'string') {
122
+ throw new Error(
123
+ '[Portal.Provider.EnclaveSigner] For raw signing, params must be a string (e.g., a hex-encoded message).',
124
+ )
125
+ }
126
+ formattedParams = params
127
+ } else {
128
+ formattedParams =
129
+ typeof params === 'string' ? params : JSON.stringify(params)
130
+ }
131
+
132
+ // Get RPC URL
133
+ const rpcUrl = isRaw ? '' : provider.getGatewayUrl(chainId)
134
+
135
+ // Set metrics operation
136
+ metrics.operation = isRaw ? Operation.RAW_SIGN : Operation.SIGN
137
+
138
+ if (typeof formattedParams !== 'string') {
139
+ throw new Error(
140
+ `[Portal.Provider.EnclaveSigner] The formatted params for the signing request could not be converted to a string. The params were: ${formattedParams}`,
141
+ )
142
+ }
143
+
144
+ // Record pre-operation time
145
+ metrics.sdkPreOperationMs = performance.now() - preOperationStartTime
146
+
147
+ // Measure enclave signing operation time
148
+ const enclaveSignStartTime = performance.now()
149
+
150
+ const result = isRaw
151
+ ? await this.enclaveRawSign(
152
+ apiKey,
153
+ JSON.stringify(signingShare),
154
+ formattedParams,
155
+ curve || 'SECP256K1',
156
+ )
157
+ : await this.enclaveSign(
158
+ apiKey,
159
+ JSON.stringify(signingShare),
160
+ message.method,
161
+ formattedParams,
162
+ rpcUrl,
163
+ chainId || '',
164
+ JSON.stringify(metadata),
165
+ )
166
+
167
+ // Post-operation processing time starts
168
+ const postOperationStartTime = performance.now()
169
+
170
+ // Record HTTP call time
171
+ metrics.enclaveHttpCallMs = performance.now() - enclaveSignStartTime
172
+
173
+ // Record post-operation time
174
+ metrics.sdkPostOperationMs = performance.now() - postOperationStartTime
175
+
176
+ // Calculate total SDK signing time
177
+ metrics.sdkOperationMs = performance.now() - signStartTime
178
+
179
+ // Log performance timing to console
180
+ const timingMetrics: Record<string, number> = {}
181
+ if (typeof metrics.sdkPreOperationMs === 'number') {
182
+ timingMetrics['Pre-operation'] = metrics.sdkPreOperationMs
183
+ }
184
+ if (typeof metrics.enclaveHttpCallMs === 'number') {
185
+ timingMetrics['Enclave HTTP Call'] = metrics.enclaveHttpCallMs
186
+ }
187
+ if (typeof metrics.sdkPostOperationMs === 'number') {
188
+ timingMetrics['Post-operation'] = metrics.sdkPostOperationMs
189
+ }
190
+
191
+ // Only send metrics if the feature flag is enabled
192
+ if (shouldSendMetrics && this.portalApi) {
193
+ try {
194
+ await this.sendMetrics(metrics, apiKey)
195
+ } catch (err) {
196
+ // No-op
197
+ }
198
+ }
199
+
200
+ return result
201
+ } catch (error) {
202
+ // Calculate total time even in error case
203
+ metrics.sdkOperationMs = performance.now() - signStartTime
204
+
205
+ // Log performance timing to console even in error case
206
+ const errorTimingMetrics: Record<string, number> = {}
207
+ if (typeof metrics.sdkPreOperationMs === 'number') {
208
+ errorTimingMetrics['Pre-operation'] = metrics.sdkPreOperationMs
209
+ }
210
+
211
+ // Only send metrics if the feature flag is enabled
212
+ if (shouldSendMetrics) {
213
+ const apiKey = provider.apiKey
214
+ metrics.hasError = true
215
+ try {
216
+ await this.sendMetrics(metrics, apiKey)
217
+ } catch {
218
+ // No-op
219
+ }
220
+ }
221
+
222
+ throw error
223
+ }
224
+ }
225
+
226
+ private async enclaveRawSign(
227
+ apiKey: string,
228
+ signingShare: string,
229
+ params: string,
230
+ curve: string,
231
+ ): Promise<string> {
232
+ if (!apiKey || !signingShare || !params || !curve) {
233
+ return this.encodeErrorResult(
234
+ 'INVALID_PARAMETERS',
235
+ 'Invalid parameters provided for raw signing',
236
+ )
237
+ }
238
+
239
+ const requestBody = {
240
+ params: params,
241
+ share: signingShare,
242
+ }
243
+
244
+ const endpoint = `/v1/raw/sign/${curve}`
245
+
246
+ try {
247
+ const response = await this.requests.post<EnclaveSignResponse>(endpoint, {
248
+ headers: {
249
+ Authorization: `Bearer ${apiKey}`,
250
+ 'Content-Type': 'application/json',
251
+ },
252
+ body: requestBody,
253
+ })
254
+
255
+ return this.encodeSuccessResult(response.data)
256
+ } catch (error: any) {
257
+ if (error?.response?.data) {
258
+ const portalError = this.decodePortalError(
259
+ JSON.stringify(error.response.data),
260
+ )
261
+ return this.encodeErrorResult(portalError?.id, portalError?.message)
262
+ }
263
+ return this.encodeErrorResult(
264
+ 'SIGNING_NETWORK_ERROR',
265
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument
266
+ error.message || 'Network error occurred',
267
+ )
268
+ }
269
+ }
270
+
271
+ private async enclaveSign(
272
+ apiKey: string,
273
+ signingShare: string,
274
+ method: string,
275
+ params: string,
276
+ rpcURL: string,
277
+ chainId: string,
278
+ metadata: string,
279
+ ): Promise<string> {
280
+ if (
281
+ !apiKey ||
282
+ !signingShare ||
283
+ !method ||
284
+ !params ||
285
+ !chainId ||
286
+ !metadata
287
+ ) {
288
+ return this.encodeErrorResult(
289
+ 'INVALID_PARAMETERS',
290
+ 'Invalid parameters provided',
291
+ )
292
+ }
293
+
294
+ const requestBody: EnclaveSignRequest = {
295
+ method: method,
296
+ params: params,
297
+ share: signingShare,
298
+ chainId: chainId,
299
+ rpcUrl: rpcURL,
300
+ metadataStr: metadata,
301
+ clientPlatform: 'REACT_NATIVE',
302
+ clientPlatformVersion: getClientPlatformVersion(),
303
+ }
304
+
305
+ try {
306
+ const response = await this.requests.post<EnclaveSignResponse>(
307
+ '/v1/sign',
308
+ {
309
+ headers: {
310
+ Authorization: `Bearer ${apiKey}`,
311
+ 'Content-Type': 'application/json',
312
+ },
313
+ body: requestBody,
314
+ },
315
+ )
316
+
317
+ return this.encodeSuccessResult(response.data)
318
+ } catch (error: any) {
319
+ if (error?.response?.data) {
320
+ const portalError = this.decodePortalError(
321
+ JSON.stringify(error.response.data),
322
+ )
323
+ return this.encodeErrorResult(portalError?.id, portalError?.message)
324
+ }
325
+ return this.encodeErrorResult(
326
+ 'SIGNING_NETWORK_ERROR',
327
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument
328
+ error.message || 'Network error occurred',
329
+ )
330
+ }
331
+ }
332
+
333
+ // Helper function to encode success results
334
+ private encodeSuccessResult(data: string): string {
335
+ const successResult: EnclaveSignResult = { data: data, error: undefined }
336
+ return this.encodeJSON(successResult)
337
+ }
338
+
339
+ // Helper function to decode Portal errors
340
+ private decodePortalError(
341
+ errorStr?: string,
342
+ ): { id?: string; message?: string } | null {
343
+ if (!errorStr) return null
344
+ try {
345
+ return JSON.parse(errorStr)
346
+ } catch {
347
+ return null
348
+ }
349
+ }
350
+
351
+ // Helper function to encode error results
352
+ private encodeErrorResult(id?: string, message?: string): string {
353
+ const errorResult: EnclaveSignResult = {
354
+ data: undefined,
355
+ error: { id, message },
356
+ }
357
+ return this.encodeJSON(errorResult)
358
+ }
359
+
360
+ // Helper function to encode any object to JSON string
361
+ private encodeJSON<T>(value: T): string {
362
+ try {
363
+ const jsonString = JSON.stringify(value)
364
+ return jsonString
365
+ } catch (error: any) {
366
+ return JSON.stringify({
367
+ error: {
368
+ id: 'ENCODING_ERROR',
369
+ message: `Failed to encode JSON: ${error.message}`,
370
+ },
371
+ })
372
+ }
373
+ }
374
+
375
+ private async sendMetrics(
376
+ metrics: Record<string, number | string | boolean>,
377
+ apiKey: string,
378
+ ): Promise<void> {
379
+ try {
380
+ if (this.portalApi) {
381
+ await this.portalApi.post('/api/v3/clients/me/sdk/metrics', {
382
+ headers: {
383
+ Authorization: `Bearer ${apiKey}`,
384
+ 'Content-Type': 'application/json',
385
+ },
386
+ body: metrics,
387
+ })
388
+ }
389
+ } catch {
390
+ // No-op
391
+ }
392
+ }
393
+
394
+ private buildParams = (method: string, txParams: any) => {
395
+ let params = txParams
396
+
397
+ switch (method) {
398
+ case 'eth_sign':
399
+ case 'personal_sign':
400
+ case 'eth_signTypedData_v3':
401
+ case 'eth_signTypedData_v4':
402
+ case 'sol_signMessage':
403
+ case 'sol_signTransaction':
404
+ case 'sol_signAndSendTransaction':
405
+ case 'sol_signAndConfirmTransaction':
406
+ if (!Array.isArray(txParams)) {
407
+ params = [txParams]
408
+ }
409
+ break
410
+ default:
411
+ if (Array.isArray(txParams)) {
412
+ if (txParams.length === 1) {
413
+ params = txParams[0]
414
+ }
415
+ }
416
+ }
417
+ return params
418
+ }
419
+ }
420
+
421
+ 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'
@@ -189,7 +189,7 @@ class MpcSigner implements Signer {
189
189
  }
190
190
  }
191
191
 
192
- if (error?.code > 0) {
192
+ if (error?.id) {
193
193
  throw new PortalMpcError(error)
194
194
  }
195
195
 
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
  }
@@ -86,6 +87,7 @@ export interface ProviderOptions {
86
87
  autoApprove?: boolean
87
88
  apiHost?: string
88
89
  mpcHost?: string
90
+ enclaveMPCHost?: string
89
91
  version?: string
90
92
  featureFlags?: FeatureFlags
91
93
  }
@@ -106,6 +108,30 @@ export interface SignResult {
106
108
  S: string
107
109
  }
108
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
+
109
135
  export interface SignerOptions {}
110
136
 
111
137
  export interface Eip1559 {
@@ -141,3 +167,22 @@ export interface SigningResponse {
141
167
  export interface SwitchEthereumChainParameter {
142
168
  chainId: number
143
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
+ }