@portal-hq/web 3.14.0 → 3.15.0

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.
@@ -316,6 +316,9 @@ class Portal {
316
316
  yield this.storedClientBackupShare(true, BackupMethods.passkey);
317
317
  });
318
318
  }
319
+ configureFirebaseStorage(options) {
320
+ this.mpc.configureFirebaseStorage(options);
321
+ }
319
322
  backupWallet(backupMethod, progress = () => {
320
323
  // Noop
321
324
  }, backupConfigs = {}) {
@@ -1144,6 +1147,7 @@ var BackupMethods;
1144
1147
  BackupMethods["password"] = "PASSWORD";
1145
1148
  BackupMethods["passkey"] = "PASSKEY";
1146
1149
  BackupMethods["custom"] = "CUSTOM";
1150
+ BackupMethods["firebase"] = "FIREBASE";
1147
1151
  BackupMethods["unknown"] = "UNKNOWN";
1148
1152
  })(BackupMethods = exports.BackupMethods || (exports.BackupMethods = {}));
1149
1153
  var GetTransactionsOrder;
@@ -14,7 +14,7 @@ const errors_1 = require("./errors");
14
14
  const logger_1 = require("../logger");
15
15
  const index_1 = require("../index");
16
16
  const trace_1 = require("../shared/trace");
17
- const WEB_SDK_VERSION = '3.14.0';
17
+ const WEB_SDK_VERSION = '3.15.0';
18
18
  class Mpc {
19
19
  get ready() {
20
20
  return this._ready;
@@ -25,6 +25,47 @@ class Mpc {
25
25
  constructor({ portal }) {
26
26
  this._ready = false;
27
27
  this.presignatureLogHandler = null;
28
+ this.boundFirebaseTokenBridge = (event) => {
29
+ var _a;
30
+ const { origin } = event;
31
+ if (origin !== this.getOrigin()) {
32
+ return;
33
+ }
34
+ if (event.source !== ((_a = this.iframe) === null || _a === void 0 ? void 0 : _a.contentWindow)) {
35
+ return;
36
+ }
37
+ const { type, data } = event.data || {};
38
+ if (type !== 'portal:firebase:requestToken') {
39
+ return;
40
+ }
41
+ const requestId = data === null || data === void 0 ? void 0 : data.requestId;
42
+ if (!requestId) {
43
+ return;
44
+ }
45
+ const getter = this.firebaseGetToken;
46
+ void (() => __awaiter(this, void 0, void 0, function* () {
47
+ try {
48
+ if (!getter) {
49
+ throw new Error('Firebase storage is not configured (getToken missing)');
50
+ }
51
+ const token = yield getter({
52
+ forceRefresh: (data === null || data === void 0 ? void 0 : data.forceRefresh) === true,
53
+ });
54
+ this.postMessage({
55
+ type: 'portal:firebase:requestTokenResult',
56
+ data: { requestId, token },
57
+ });
58
+ }
59
+ catch (e) {
60
+ const message = (e instanceof Error ? e.message : String(e)) ||
61
+ 'Unknown firebase token error';
62
+ this.postMessage({
63
+ type: 'portal:firebase:requestTokenError',
64
+ data: { requestId, message },
65
+ });
66
+ }
67
+ }))();
68
+ };
28
69
  this.configureIframe = () => {
29
70
  const config = {
30
71
  apiKey: this.portal.apiKey,
@@ -54,6 +95,14 @@ class Mpc {
54
95
  this.configureIframe = this.configureIframe.bind(this);
55
96
  // Create the iFrame for MPC operations
56
97
  this.appendIframe();
98
+ window.addEventListener('message', this.boundFirebaseTokenBridge);
99
+ }
100
+ configureFirebaseStorage(options) {
101
+ this.firebaseGetToken = options.getToken;
102
+ this.postMessage({
103
+ type: 'portal:firebase:configure',
104
+ data: { tbsHost: options.tbsHost },
105
+ });
57
106
  }
58
107
  /*******************************
59
108
  * Wallet Methods
@@ -30,6 +30,53 @@ describe('Mpc', () => {
30
30
  portal: portal_1.default,
31
31
  });
32
32
  });
33
+ describe('configureFirebaseStorage', () => {
34
+ it('posts configure to iframe and bridges token requests from iframe', () => __awaiter(void 0, void 0, void 0, function* () {
35
+ const postSpy = jest
36
+ .spyOn(mpc.iframe.contentWindow, 'postMessage')
37
+ .mockImplementation(() => { });
38
+ mpc.configureFirebaseStorage({
39
+ getToken: (opts) => __awaiter(void 0, void 0, void 0, function* () { return (opts === null || opts === void 0 ? void 0 : opts.forceRefresh) ? 'firebase-jwt-refreshed' : 'firebase-jwt'; }),
40
+ tbsHost: 'backup.web.portalhq.io',
41
+ });
42
+ expect(postSpy).toHaveBeenNthCalledWith(1, {
43
+ type: 'portal:firebase:configure',
44
+ data: { tbsHost: 'backup.web.portalhq.io' },
45
+ }, mockHostOrigin);
46
+ const bridge = mpc
47
+ .boundFirebaseTokenBridge;
48
+ bridge(new MessageEvent('message', {
49
+ origin: mockHostOrigin,
50
+ source: mpc.iframe.contentWindow,
51
+ data: {
52
+ type: 'portal:firebase:requestToken',
53
+ data: { requestId: 'r1' },
54
+ },
55
+ }));
56
+ yield new Promise((resolve) => {
57
+ setTimeout(resolve, 0);
58
+ });
59
+ expect(postSpy).toHaveBeenNthCalledWith(2, {
60
+ type: 'portal:firebase:requestTokenResult',
61
+ data: { requestId: 'r1', token: 'firebase-jwt' },
62
+ }, mockHostOrigin);
63
+ bridge(new MessageEvent('message', {
64
+ origin: mockHostOrigin,
65
+ source: mpc.iframe.contentWindow,
66
+ data: {
67
+ type: 'portal:firebase:requestToken',
68
+ data: { requestId: 'r2', forceRefresh: true },
69
+ },
70
+ }));
71
+ yield new Promise((resolve) => {
72
+ setTimeout(resolve, 0);
73
+ });
74
+ expect(postSpy).toHaveBeenNthCalledWith(3, {
75
+ type: 'portal:firebase:requestTokenResult',
76
+ data: { requestId: 'r2', token: 'firebase-jwt-refreshed' },
77
+ }, mockHostOrigin);
78
+ }));
79
+ });
33
80
  describe('backup', () => {
34
81
  const args = {
35
82
  backupMethod: index_1.BackupMethods.password,
@@ -267,7 +314,7 @@ describe('Mpc', () => {
267
314
  })
268
315
  .catch((e) => {
269
316
  expect(e).toBeInstanceOf(Error);
270
- expect(e.message).toEqual('Invalid backup method: INVALID_METHOD. Valid methods are: GDRIVE, PASSWORD, PASSKEY, CUSTOM, UNKNOWN');
317
+ expect(e.message).toEqual('Invalid backup method: INVALID_METHOD. Valid methods are: GDRIVE, PASSWORD, PASSKEY, CUSTOM, FIREBASE, UNKNOWN');
271
318
  done();
272
319
  });
273
320
  });
@@ -58,6 +58,7 @@ var RequestMethod;
58
58
  RequestMethod["eth_sendTransaction"] = "eth_sendTransaction";
59
59
  RequestMethod["eth_sign"] = "eth_sign";
60
60
  RequestMethod["eth_signTransaction"] = "eth_signTransaction";
61
+ RequestMethod["eth_signUserOperation"] = "eth_signUserOperation";
61
62
  RequestMethod["eth_signTypedData"] = "eth_signTypedData";
62
63
  RequestMethod["eth_signTypedData_v3"] = "eth_signTypedData_v3";
63
64
  RequestMethod["eth_signTypedData_v4"] = "eth_signTypedData_v4";
@@ -144,6 +145,7 @@ const signerMethods = [
144
145
  RequestMethod.eth_sendTransaction,
145
146
  RequestMethod.eth_sign,
146
147
  RequestMethod.eth_signTransaction,
148
+ RequestMethod.eth_signUserOperation,
147
149
  RequestMethod.eth_signTypedData_v3,
148
150
  RequestMethod.eth_signTypedData_v4,
149
151
  RequestMethod.personal_sign,
@@ -447,6 +449,7 @@ class Provider {
447
449
  case RequestMethod.eth_sendTransaction:
448
450
  case RequestMethod.eth_sign:
449
451
  case RequestMethod.eth_signTransaction:
452
+ case RequestMethod.eth_signUserOperation:
450
453
  case RequestMethod.eth_signTypedData_v3:
451
454
  case RequestMethod.eth_signTypedData_v4:
452
455
  case RequestMethod.personal_sign: {
@@ -206,6 +206,53 @@ describe('Provider', () => {
206
206
  });
207
207
  }));
208
208
  });
209
+ describe('eth_signUserOperation', () => {
210
+ const mockUserOperation = {
211
+ sender: '0x1234567890123456789012345678901234567890',
212
+ callData: '0x',
213
+ nonce: '0x0',
214
+ maxFeePerGas: '0x3B9ACA00',
215
+ maxPriorityFeePerGas: '0x3B9ACA00',
216
+ signature: '0x',
217
+ };
218
+ it('should throw an error if no chainId is provided alongside eth_signUserOperation', () => __awaiter(void 0, void 0, void 0, function* () {
219
+ expect(provider.request({
220
+ method: __1.RequestMethod.eth_signUserOperation,
221
+ params: [mockUserOperation],
222
+ })).rejects.toThrow(new Error(`[PortalProvider] Chain ID is required for the operation`));
223
+ }));
224
+ it('should throw an error if malformed chainId is provided alongside eth_signUserOperation', () => __awaiter(void 0, void 0, void 0, function* () {
225
+ const chainId = 'unsupported:chain';
226
+ expect(provider.request({
227
+ chainId,
228
+ method: __1.RequestMethod.eth_signUserOperation,
229
+ params: [mockUserOperation],
230
+ })).rejects.toThrow(new Error(`[PortalProvider] Chain ID must be prefixed with "eip155:" for the operation, got ${chainId}`));
231
+ }));
232
+ it('should successfully handle an eth_signUserOperation request', () => __awaiter(void 0, void 0, void 0, function* () {
233
+ const result = yield provider.request({
234
+ chainId: 'eip155:1',
235
+ method: __1.RequestMethod.eth_signUserOperation,
236
+ params: [mockUserOperation],
237
+ signatureApprovalMemo: 'Test eth_signUserOperation',
238
+ });
239
+ expect(result).toEqual(constants_1.mockSignedHash);
240
+ expect(portal_1.default.mpc.sign).toHaveBeenCalledWith({
241
+ chainId: 'eip155:1',
242
+ method: __1.RequestMethod.eth_signUserOperation,
243
+ params: mockUserOperation,
244
+ rpcUrl: constants_1.mockRpcUrl,
245
+ signatureApprovalMemo: 'Test eth_signUserOperation',
246
+ traceId: 'mock-trace-id-12345',
247
+ });
248
+ expect(provider.emit).toHaveBeenCalledWith('portal_signatureReceived', {
249
+ chainId: 'eip155:1',
250
+ method: __1.RequestMethod.eth_signUserOperation,
251
+ params: [mockUserOperation],
252
+ signature: result,
253
+ });
254
+ }));
255
+ });
209
256
  describe('eth_sign', () => {
210
257
  it('should throw an error if no chainId is provided alongside eth_sign', () => __awaiter(void 0, void 0, void 0, function* () {
211
258
  expect(provider.request({
@@ -674,6 +721,69 @@ describe('Provider', () => {
674
721
  expect(mockConsoleWarn).toHaveBeenCalledWith("[PortalProvider] Request for signing method 'eth_signTransaction' could not be completed because it was not approved by the user.");
675
722
  }));
676
723
  });
724
+ describe('eth_signUserOperation', () => {
725
+ const mockUserOperation = {
726
+ sender: '0x1234567890123456789012345678901234567890',
727
+ callData: '0x',
728
+ nonce: '0x0',
729
+ maxFeePerGas: '0x3B9ACA00',
730
+ maxPriorityFeePerGas: '0x3B9ACA00',
731
+ signature: '0x',
732
+ };
733
+ it('should successfully handle an approved eth_signUserOperation request', () => __awaiter(void 0, void 0, void 0, function* () {
734
+ provider.on('portal_signingRequested', () => {
735
+ provider.emit('portal_signingApproved', {
736
+ method: __1.RequestMethod.eth_signUserOperation,
737
+ params: [mockUserOperation],
738
+ });
739
+ });
740
+ const result = yield provider.request({
741
+ chainId: 'eip155:1',
742
+ method: __1.RequestMethod.eth_signUserOperation,
743
+ params: [mockUserOperation],
744
+ signatureApprovalMemo: 'Test eth_signUserOperation',
745
+ });
746
+ expect(mockSigningRequestedHandler).toHaveBeenCalledWith({
747
+ method: __1.RequestMethod.eth_signUserOperation,
748
+ params: [mockUserOperation],
749
+ signatureApprovalMemo: 'Test eth_signUserOperation',
750
+ });
751
+ expect(result).toEqual(constants_1.mockSignedHash);
752
+ expect(portal_1.default.mpc.sign).toHaveBeenCalledWith({
753
+ chainId: 'eip155:1',
754
+ method: __1.RequestMethod.eth_signUserOperation,
755
+ params: mockUserOperation,
756
+ rpcUrl: constants_1.mockRpcUrl,
757
+ signatureApprovalMemo: 'Test eth_signUserOperation',
758
+ traceId: 'mock-trace-id-12345',
759
+ });
760
+ expect(mockSignatureReceivedHandler).toHaveBeenCalledWith({
761
+ chainId: 'eip155:1',
762
+ method: __1.RequestMethod.eth_signUserOperation,
763
+ params: [mockUserOperation],
764
+ signature: result,
765
+ });
766
+ }));
767
+ it('should successfully handle a rejected eth_signUserOperation request', () => __awaiter(void 0, void 0, void 0, function* () {
768
+ provider.on('portal_signingRequested', () => {
769
+ provider.emit('portal_signingRejected', {
770
+ method: __1.RequestMethod.eth_signUserOperation,
771
+ params: [mockUserOperation],
772
+ });
773
+ });
774
+ const result = yield provider.request({
775
+ chainId: 'eip155:1',
776
+ method: __1.RequestMethod.eth_signUserOperation,
777
+ params: [mockUserOperation],
778
+ });
779
+ expect(mockSigningRequestedHandler).toHaveBeenCalledWith({
780
+ method: __1.RequestMethod.eth_signUserOperation,
781
+ params: [mockUserOperation],
782
+ });
783
+ expect(result).toEqual(undefined);
784
+ expect(mockConsoleWarn).toHaveBeenCalledWith("[PortalProvider] Request for signing method 'eth_signUserOperation' could not be completed because it was not approved by the user.");
785
+ }));
786
+ });
677
787
  describe('eth_sign', () => {
678
788
  it('should successfully handle an approved eth_sign request', () => __awaiter(void 0, void 0, void 0, function* () {
679
789
  provider.on('portal_signingRequested', () => {
package/lib/esm/index.js CHANGED
@@ -287,6 +287,9 @@ class Portal {
287
287
  yield this.storedClientBackupShare(true, BackupMethods.passkey);
288
288
  });
289
289
  }
290
+ configureFirebaseStorage(options) {
291
+ this.mpc.configureFirebaseStorage(options);
292
+ }
290
293
  backupWallet(backupMethod, progress = () => {
291
294
  // Noop
292
295
  }, backupConfigs = {}) {
@@ -1111,6 +1114,7 @@ export var BackupMethods;
1111
1114
  BackupMethods["password"] = "PASSWORD";
1112
1115
  BackupMethods["passkey"] = "PASSKEY";
1113
1116
  BackupMethods["custom"] = "CUSTOM";
1117
+ BackupMethods["firebase"] = "FIREBASE";
1114
1118
  BackupMethods["unknown"] = "UNKNOWN";
1115
1119
  })(BackupMethods || (BackupMethods = {}));
1116
1120
  export var GetTransactionsOrder;
@@ -11,7 +11,7 @@ import { PortalMpcError } from './errors';
11
11
  import { sdkLogger } from '../logger';
12
12
  import { BackupMethods, } from '../index';
13
13
  import { generateTraceId } from '../shared/trace';
14
- const WEB_SDK_VERSION = '3.14.0';
14
+ const WEB_SDK_VERSION = '3.15.0';
15
15
  class Mpc {
16
16
  get ready() {
17
17
  return this._ready;
@@ -22,6 +22,47 @@ class Mpc {
22
22
  constructor({ portal }) {
23
23
  this._ready = false;
24
24
  this.presignatureLogHandler = null;
25
+ this.boundFirebaseTokenBridge = (event) => {
26
+ var _a;
27
+ const { origin } = event;
28
+ if (origin !== this.getOrigin()) {
29
+ return;
30
+ }
31
+ if (event.source !== ((_a = this.iframe) === null || _a === void 0 ? void 0 : _a.contentWindow)) {
32
+ return;
33
+ }
34
+ const { type, data } = event.data || {};
35
+ if (type !== 'portal:firebase:requestToken') {
36
+ return;
37
+ }
38
+ const requestId = data === null || data === void 0 ? void 0 : data.requestId;
39
+ if (!requestId) {
40
+ return;
41
+ }
42
+ const getter = this.firebaseGetToken;
43
+ void (() => __awaiter(this, void 0, void 0, function* () {
44
+ try {
45
+ if (!getter) {
46
+ throw new Error('Firebase storage is not configured (getToken missing)');
47
+ }
48
+ const token = yield getter({
49
+ forceRefresh: (data === null || data === void 0 ? void 0 : data.forceRefresh) === true,
50
+ });
51
+ this.postMessage({
52
+ type: 'portal:firebase:requestTokenResult',
53
+ data: { requestId, token },
54
+ });
55
+ }
56
+ catch (e) {
57
+ const message = (e instanceof Error ? e.message : String(e)) ||
58
+ 'Unknown firebase token error';
59
+ this.postMessage({
60
+ type: 'portal:firebase:requestTokenError',
61
+ data: { requestId, message },
62
+ });
63
+ }
64
+ }))();
65
+ };
25
66
  this.configureIframe = () => {
26
67
  const config = {
27
68
  apiKey: this.portal.apiKey,
@@ -51,6 +92,14 @@ class Mpc {
51
92
  this.configureIframe = this.configureIframe.bind(this);
52
93
  // Create the iFrame for MPC operations
53
94
  this.appendIframe();
95
+ window.addEventListener('message', this.boundFirebaseTokenBridge);
96
+ }
97
+ configureFirebaseStorage(options) {
98
+ this.firebaseGetToken = options.getToken;
99
+ this.postMessage({
100
+ type: 'portal:firebase:configure',
101
+ data: { tbsHost: options.tbsHost },
102
+ });
54
103
  }
55
104
  /*******************************
56
105
  * Wallet Methods
@@ -25,6 +25,53 @@ describe('Mpc', () => {
25
25
  portal: portalMock,
26
26
  });
27
27
  });
28
+ describe('configureFirebaseStorage', () => {
29
+ it('posts configure to iframe and bridges token requests from iframe', () => __awaiter(void 0, void 0, void 0, function* () {
30
+ const postSpy = jest
31
+ .spyOn(mpc.iframe.contentWindow, 'postMessage')
32
+ .mockImplementation(() => { });
33
+ mpc.configureFirebaseStorage({
34
+ getToken: (opts) => __awaiter(void 0, void 0, void 0, function* () { return (opts === null || opts === void 0 ? void 0 : opts.forceRefresh) ? 'firebase-jwt-refreshed' : 'firebase-jwt'; }),
35
+ tbsHost: 'backup.web.portalhq.io',
36
+ });
37
+ expect(postSpy).toHaveBeenNthCalledWith(1, {
38
+ type: 'portal:firebase:configure',
39
+ data: { tbsHost: 'backup.web.portalhq.io' },
40
+ }, mockHostOrigin);
41
+ const bridge = mpc
42
+ .boundFirebaseTokenBridge;
43
+ bridge(new MessageEvent('message', {
44
+ origin: mockHostOrigin,
45
+ source: mpc.iframe.contentWindow,
46
+ data: {
47
+ type: 'portal:firebase:requestToken',
48
+ data: { requestId: 'r1' },
49
+ },
50
+ }));
51
+ yield new Promise((resolve) => {
52
+ setTimeout(resolve, 0);
53
+ });
54
+ expect(postSpy).toHaveBeenNthCalledWith(2, {
55
+ type: 'portal:firebase:requestTokenResult',
56
+ data: { requestId: 'r1', token: 'firebase-jwt' },
57
+ }, mockHostOrigin);
58
+ bridge(new MessageEvent('message', {
59
+ origin: mockHostOrigin,
60
+ source: mpc.iframe.contentWindow,
61
+ data: {
62
+ type: 'portal:firebase:requestToken',
63
+ data: { requestId: 'r2', forceRefresh: true },
64
+ },
65
+ }));
66
+ yield new Promise((resolve) => {
67
+ setTimeout(resolve, 0);
68
+ });
69
+ expect(postSpy).toHaveBeenNthCalledWith(3, {
70
+ type: 'portal:firebase:requestTokenResult',
71
+ data: { requestId: 'r2', token: 'firebase-jwt-refreshed' },
72
+ }, mockHostOrigin);
73
+ }));
74
+ });
28
75
  describe('backup', () => {
29
76
  const args = {
30
77
  backupMethod: BackupMethods.password,
@@ -262,7 +309,7 @@ describe('Mpc', () => {
262
309
  })
263
310
  .catch((e) => {
264
311
  expect(e).toBeInstanceOf(Error);
265
- expect(e.message).toEqual('Invalid backup method: INVALID_METHOD. Valid methods are: GDRIVE, PASSWORD, PASSKEY, CUSTOM, UNKNOWN');
312
+ expect(e.message).toEqual('Invalid backup method: INVALID_METHOD. Valid methods are: GDRIVE, PASSWORD, PASSKEY, CUSTOM, FIREBASE, UNKNOWN');
266
313
  done();
267
314
  });
268
315
  });
@@ -55,6 +55,7 @@ export var RequestMethod;
55
55
  RequestMethod["eth_sendTransaction"] = "eth_sendTransaction";
56
56
  RequestMethod["eth_sign"] = "eth_sign";
57
57
  RequestMethod["eth_signTransaction"] = "eth_signTransaction";
58
+ RequestMethod["eth_signUserOperation"] = "eth_signUserOperation";
58
59
  RequestMethod["eth_signTypedData"] = "eth_signTypedData";
59
60
  RequestMethod["eth_signTypedData_v3"] = "eth_signTypedData_v3";
60
61
  RequestMethod["eth_signTypedData_v4"] = "eth_signTypedData_v4";
@@ -141,6 +142,7 @@ const signerMethods = [
141
142
  RequestMethod.eth_sendTransaction,
142
143
  RequestMethod.eth_sign,
143
144
  RequestMethod.eth_signTransaction,
145
+ RequestMethod.eth_signUserOperation,
144
146
  RequestMethod.eth_signTypedData_v3,
145
147
  RequestMethod.eth_signTypedData_v4,
146
148
  RequestMethod.personal_sign,
@@ -444,6 +446,7 @@ class Provider {
444
446
  case RequestMethod.eth_sendTransaction:
445
447
  case RequestMethod.eth_sign:
446
448
  case RequestMethod.eth_signTransaction:
449
+ case RequestMethod.eth_signUserOperation:
447
450
  case RequestMethod.eth_signTypedData_v3:
448
451
  case RequestMethod.eth_signTypedData_v4:
449
452
  case RequestMethod.personal_sign: {
@@ -201,6 +201,53 @@ describe('Provider', () => {
201
201
  });
202
202
  }));
203
203
  });
204
+ describe('eth_signUserOperation', () => {
205
+ const mockUserOperation = {
206
+ sender: '0x1234567890123456789012345678901234567890',
207
+ callData: '0x',
208
+ nonce: '0x0',
209
+ maxFeePerGas: '0x3B9ACA00',
210
+ maxPriorityFeePerGas: '0x3B9ACA00',
211
+ signature: '0x',
212
+ };
213
+ it('should throw an error if no chainId is provided alongside eth_signUserOperation', () => __awaiter(void 0, void 0, void 0, function* () {
214
+ expect(provider.request({
215
+ method: RequestMethod.eth_signUserOperation,
216
+ params: [mockUserOperation],
217
+ })).rejects.toThrow(new Error(`[PortalProvider] Chain ID is required for the operation`));
218
+ }));
219
+ it('should throw an error if malformed chainId is provided alongside eth_signUserOperation', () => __awaiter(void 0, void 0, void 0, function* () {
220
+ const chainId = 'unsupported:chain';
221
+ expect(provider.request({
222
+ chainId,
223
+ method: RequestMethod.eth_signUserOperation,
224
+ params: [mockUserOperation],
225
+ })).rejects.toThrow(new Error(`[PortalProvider] Chain ID must be prefixed with "eip155:" for the operation, got ${chainId}`));
226
+ }));
227
+ it('should successfully handle an eth_signUserOperation request', () => __awaiter(void 0, void 0, void 0, function* () {
228
+ const result = yield provider.request({
229
+ chainId: 'eip155:1',
230
+ method: RequestMethod.eth_signUserOperation,
231
+ params: [mockUserOperation],
232
+ signatureApprovalMemo: 'Test eth_signUserOperation',
233
+ });
234
+ expect(result).toEqual(mockSignedHash);
235
+ expect(portal.mpc.sign).toHaveBeenCalledWith({
236
+ chainId: 'eip155:1',
237
+ method: RequestMethod.eth_signUserOperation,
238
+ params: mockUserOperation,
239
+ rpcUrl: mockRpcUrl,
240
+ signatureApprovalMemo: 'Test eth_signUserOperation',
241
+ traceId: 'mock-trace-id-12345',
242
+ });
243
+ expect(provider.emit).toHaveBeenCalledWith('portal_signatureReceived', {
244
+ chainId: 'eip155:1',
245
+ method: RequestMethod.eth_signUserOperation,
246
+ params: [mockUserOperation],
247
+ signature: result,
248
+ });
249
+ }));
250
+ });
204
251
  describe('eth_sign', () => {
205
252
  it('should throw an error if no chainId is provided alongside eth_sign', () => __awaiter(void 0, void 0, void 0, function* () {
206
253
  expect(provider.request({
@@ -669,6 +716,69 @@ describe('Provider', () => {
669
716
  expect(mockConsoleWarn).toHaveBeenCalledWith("[PortalProvider] Request for signing method 'eth_signTransaction' could not be completed because it was not approved by the user.");
670
717
  }));
671
718
  });
719
+ describe('eth_signUserOperation', () => {
720
+ const mockUserOperation = {
721
+ sender: '0x1234567890123456789012345678901234567890',
722
+ callData: '0x',
723
+ nonce: '0x0',
724
+ maxFeePerGas: '0x3B9ACA00',
725
+ maxPriorityFeePerGas: '0x3B9ACA00',
726
+ signature: '0x',
727
+ };
728
+ it('should successfully handle an approved eth_signUserOperation request', () => __awaiter(void 0, void 0, void 0, function* () {
729
+ provider.on('portal_signingRequested', () => {
730
+ provider.emit('portal_signingApproved', {
731
+ method: RequestMethod.eth_signUserOperation,
732
+ params: [mockUserOperation],
733
+ });
734
+ });
735
+ const result = yield provider.request({
736
+ chainId: 'eip155:1',
737
+ method: RequestMethod.eth_signUserOperation,
738
+ params: [mockUserOperation],
739
+ signatureApprovalMemo: 'Test eth_signUserOperation',
740
+ });
741
+ expect(mockSigningRequestedHandler).toHaveBeenCalledWith({
742
+ method: RequestMethod.eth_signUserOperation,
743
+ params: [mockUserOperation],
744
+ signatureApprovalMemo: 'Test eth_signUserOperation',
745
+ });
746
+ expect(result).toEqual(mockSignedHash);
747
+ expect(portal.mpc.sign).toHaveBeenCalledWith({
748
+ chainId: 'eip155:1',
749
+ method: RequestMethod.eth_signUserOperation,
750
+ params: mockUserOperation,
751
+ rpcUrl: mockRpcUrl,
752
+ signatureApprovalMemo: 'Test eth_signUserOperation',
753
+ traceId: 'mock-trace-id-12345',
754
+ });
755
+ expect(mockSignatureReceivedHandler).toHaveBeenCalledWith({
756
+ chainId: 'eip155:1',
757
+ method: RequestMethod.eth_signUserOperation,
758
+ params: [mockUserOperation],
759
+ signature: result,
760
+ });
761
+ }));
762
+ it('should successfully handle a rejected eth_signUserOperation request', () => __awaiter(void 0, void 0, void 0, function* () {
763
+ provider.on('portal_signingRequested', () => {
764
+ provider.emit('portal_signingRejected', {
765
+ method: RequestMethod.eth_signUserOperation,
766
+ params: [mockUserOperation],
767
+ });
768
+ });
769
+ const result = yield provider.request({
770
+ chainId: 'eip155:1',
771
+ method: RequestMethod.eth_signUserOperation,
772
+ params: [mockUserOperation],
773
+ });
774
+ expect(mockSigningRequestedHandler).toHaveBeenCalledWith({
775
+ method: RequestMethod.eth_signUserOperation,
776
+ params: [mockUserOperation],
777
+ });
778
+ expect(result).toEqual(undefined);
779
+ expect(mockConsoleWarn).toHaveBeenCalledWith("[PortalProvider] Request for signing method 'eth_signUserOperation' could not be completed because it was not approved by the user.");
780
+ }));
781
+ });
672
782
  describe('eth_sign', () => {
673
783
  it('should successfully handle an approved eth_sign request', () => __awaiter(void 0, void 0, void 0, function* () {
674
784
  provider.on('portal_signingRequested', () => {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Portal MPC Support for Web",
4
4
  "author": "Portal Labs, Inc.",
5
5
  "homepage": "https://portalhq.io/",
6
- "version": "3.14.0",
6
+ "version": "3.15.0",
7
7
  "license": "MIT",
8
8
  "main": "lib/commonjs/index",
9
9
  "module": "lib/esm/index",
package/src/index.ts CHANGED
@@ -48,6 +48,7 @@ import {
48
48
  type BackupShareResult,
49
49
  type PasskeyOptions,
50
50
  type PortalOptions,
51
+ type FirebaseStorageConfigOptions,
51
52
  } from '../types'
52
53
  import Mpc from './mpc'
53
54
  import Provider, { RequestMethod } from './provider'
@@ -434,6 +435,12 @@ class Portal {
434
435
  await this.storedClientBackupShare(true, BackupMethods.passkey)
435
436
  }
436
437
 
438
+ public configureFirebaseStorage(
439
+ options: FirebaseStorageConfigOptions,
440
+ ): void {
441
+ this.mpc.configureFirebaseStorage(options)
442
+ }
443
+
437
444
  public async backupWallet(
438
445
  backupMethod: BackupMethods,
439
446
  progress: ProgressCallback = () => {
@@ -1568,6 +1575,7 @@ export { PortalMpcError } from './mpc/errors'
1568
1575
  export {
1569
1576
  type Address,
1570
1577
  type Dapp,
1578
+ type FirebaseStorageConfigOptions,
1571
1579
  type ILogger,
1572
1580
  type LogLevel,
1573
1581
  type MpcStatus,
@@ -1592,6 +1600,7 @@ export enum BackupMethods {
1592
1600
  password = 'PASSWORD',
1593
1601
  passkey = 'PASSKEY',
1594
1602
  custom = 'CUSTOM',
1603
+ firebase = 'FIREBASE',
1595
1604
  unknown = 'UNKNOWN',
1596
1605
  }
1597
1606
 
@@ -99,6 +99,75 @@ describe('Mpc', () => {
99
99
  })
100
100
  })
101
101
 
102
+ describe('configureFirebaseStorage', () => {
103
+ it('posts configure to iframe and bridges token requests from iframe', async () => {
104
+ const postSpy = jest
105
+ .spyOn(mpc.iframe!.contentWindow!, 'postMessage')
106
+ .mockImplementation(() => {})
107
+
108
+ mpc.configureFirebaseStorage({
109
+ getToken: async (opts) =>
110
+ opts?.forceRefresh ? 'firebase-jwt-refreshed' : 'firebase-jwt',
111
+ tbsHost: 'backup.web.portalhq.io',
112
+ })
113
+
114
+ expect(postSpy).toHaveBeenNthCalledWith(
115
+ 1,
116
+ {
117
+ type: 'portal:firebase:configure',
118
+ data: { tbsHost: 'backup.web.portalhq.io' },
119
+ },
120
+ mockHostOrigin,
121
+ )
122
+
123
+ const bridge = (mpc as unknown as { boundFirebaseTokenBridge: (e: MessageEvent) => void })
124
+ .boundFirebaseTokenBridge
125
+ bridge(
126
+ new MessageEvent('message', {
127
+ origin: mockHostOrigin,
128
+ source: mpc.iframe!.contentWindow!,
129
+ data: {
130
+ type: 'portal:firebase:requestToken',
131
+ data: { requestId: 'r1' },
132
+ },
133
+ }),
134
+ )
135
+ await new Promise<void>((resolve) => {
136
+ setTimeout(resolve, 0)
137
+ })
138
+ expect(postSpy).toHaveBeenNthCalledWith(
139
+ 2,
140
+ {
141
+ type: 'portal:firebase:requestTokenResult',
142
+ data: { requestId: 'r1', token: 'firebase-jwt' },
143
+ },
144
+ mockHostOrigin,
145
+ )
146
+
147
+ bridge(
148
+ new MessageEvent('message', {
149
+ origin: mockHostOrigin,
150
+ source: mpc.iframe!.contentWindow!,
151
+ data: {
152
+ type: 'portal:firebase:requestToken',
153
+ data: { requestId: 'r2', forceRefresh: true },
154
+ },
155
+ }),
156
+ )
157
+ await new Promise<void>((resolve) => {
158
+ setTimeout(resolve, 0)
159
+ })
160
+ expect(postSpy).toHaveBeenNthCalledWith(
161
+ 3,
162
+ {
163
+ type: 'portal:firebase:requestTokenResult',
164
+ data: { requestId: 'r2', token: 'firebase-jwt-refreshed' },
165
+ },
166
+ mockHostOrigin,
167
+ )
168
+ })
169
+ })
170
+
102
171
  describe('backup', () => {
103
172
  const args = {
104
173
  backupMethod: BackupMethods.password,
@@ -371,7 +440,7 @@ describe('Mpc', () => {
371
440
  .catch((e) => {
372
441
  expect(e).toBeInstanceOf(Error)
373
442
  expect(e.message).toEqual(
374
- 'Invalid backup method: INVALID_METHOD. Valid methods are: GDRIVE, PASSWORD, PASSKEY, CUSTOM, UNKNOWN',
443
+ 'Invalid backup method: INVALID_METHOD. Valid methods are: GDRIVE, PASSWORD, PASSKEY, CUSTOM, FIREBASE, UNKNOWN',
375
444
  )
376
445
  done()
377
446
  })
package/src/mpc/index.ts CHANGED
@@ -134,7 +134,7 @@ import {
134
134
  } from '../../hypernative'
135
135
  import { generateTraceId } from '../shared/trace'
136
136
 
137
- const WEB_SDK_VERSION = '3.14.0'
137
+ const WEB_SDK_VERSION = '3.15.0'
138
138
 
139
139
  class Mpc {
140
140
  public iframe?: HTMLIFrameElement
@@ -142,6 +142,52 @@ class Mpc {
142
142
  private portal: Portal
143
143
  private _ready = false
144
144
  private presignatureLogHandler: ((event: MessageEvent) => void) | null = null
145
+ private firebaseGetToken?: (
146
+ options?: { forceRefresh?: boolean },
147
+ ) => Promise<string | null>
148
+
149
+ private boundFirebaseTokenBridge = (event: MessageEvent) => {
150
+ const { origin } = event
151
+ if (origin !== this.getOrigin()) {
152
+ return
153
+ }
154
+ if (event.source !== this.iframe?.contentWindow) {
155
+ return
156
+ }
157
+ const { type, data } = event.data || {}
158
+ if (type !== 'portal:firebase:requestToken') {
159
+ return
160
+ }
161
+ const requestId = data?.requestId as string | undefined
162
+ if (!requestId) {
163
+ return
164
+ }
165
+ const getter = this.firebaseGetToken
166
+ void (async () => {
167
+ try {
168
+ if (!getter) {
169
+ throw new Error(
170
+ 'Firebase storage is not configured (getToken missing)',
171
+ )
172
+ }
173
+ const token = await getter({
174
+ forceRefresh: data?.forceRefresh === true,
175
+ })
176
+ this.postMessage({
177
+ type: 'portal:firebase:requestTokenResult',
178
+ data: { requestId, token },
179
+ })
180
+ } catch (e) {
181
+ const message =
182
+ (e instanceof Error ? e.message : String(e)) ||
183
+ 'Unknown firebase token error'
184
+ this.postMessage({
185
+ type: 'portal:firebase:requestTokenError',
186
+ data: { requestId, message },
187
+ })
188
+ }
189
+ })()
190
+ }
145
191
 
146
192
  public get ready() {
147
193
  return this._ready
@@ -159,6 +205,18 @@ class Mpc {
159
205
 
160
206
  // Create the iFrame for MPC operations
161
207
  this.appendIframe()
208
+ window.addEventListener('message', this.boundFirebaseTokenBridge)
209
+ }
210
+
211
+ public configureFirebaseStorage(options: {
212
+ getToken: (options?: { forceRefresh?: boolean }) => Promise<string | null>
213
+ tbsHost?: string
214
+ }): void {
215
+ this.firebaseGetToken = options.getToken
216
+ this.postMessage({
217
+ type: 'portal:firebase:configure',
218
+ data: { tbsHost: options.tbsHost },
219
+ })
162
220
  }
163
221
 
164
222
  /*******************************
@@ -298,6 +298,73 @@ describe('Provider', () => {
298
298
  })
299
299
  })
300
300
 
301
+ describe('eth_signUserOperation', () => {
302
+ const mockUserOperation = {
303
+ sender: '0x1234567890123456789012345678901234567890',
304
+ callData: '0x',
305
+ nonce: '0x0',
306
+ maxFeePerGas: '0x3B9ACA00',
307
+ maxPriorityFeePerGas: '0x3B9ACA00',
308
+ signature: '0x',
309
+ }
310
+
311
+ it('should throw an error if no chainId is provided alongside eth_signUserOperation', async () => {
312
+ expect(
313
+ provider.request({
314
+ method: RequestMethod.eth_signUserOperation,
315
+ params: [mockUserOperation],
316
+ }),
317
+ ).rejects.toThrow(
318
+ new Error(
319
+ `[PortalProvider] Chain ID is required for the operation`,
320
+ ),
321
+ )
322
+ })
323
+
324
+ it('should throw an error if malformed chainId is provided alongside eth_signUserOperation', async () => {
325
+ const chainId = 'unsupported:chain'
326
+ expect(
327
+ provider.request({
328
+ chainId,
329
+ method: RequestMethod.eth_signUserOperation,
330
+ params: [mockUserOperation],
331
+ }),
332
+ ).rejects.toThrow(
333
+ new Error(
334
+ `[PortalProvider] Chain ID must be prefixed with "eip155:" for the operation, got ${chainId}`,
335
+ ),
336
+ )
337
+ })
338
+
339
+ it('should successfully handle an eth_signUserOperation request', async () => {
340
+ const result = await provider.request({
341
+ chainId: 'eip155:1',
342
+ method: RequestMethod.eth_signUserOperation,
343
+ params: [mockUserOperation],
344
+ signatureApprovalMemo: 'Test eth_signUserOperation',
345
+ })
346
+
347
+ expect(result).toEqual(mockSignedHash)
348
+ expect(portal.mpc.sign).toHaveBeenCalledWith({
349
+ chainId: 'eip155:1',
350
+ method: RequestMethod.eth_signUserOperation,
351
+ params: mockUserOperation,
352
+ rpcUrl: mockRpcUrl,
353
+ signatureApprovalMemo: 'Test eth_signUserOperation',
354
+ traceId: 'mock-trace-id-12345',
355
+ })
356
+ expect(provider.emit).toHaveBeenCalledWith(
357
+ 'portal_signatureReceived',
358
+ {
359
+ chainId: 'eip155:1',
360
+ method: RequestMethod.eth_signUserOperation,
361
+ params: [mockUserOperation],
362
+ signature: result,
363
+ },
364
+ )
365
+ })
366
+ })
367
+
301
368
  describe('eth_sign', () => {
302
369
  it('should throw an error if no chainId is provided alongside eth_sign', async () => {
303
370
  expect(
@@ -939,6 +1006,76 @@ describe('Provider', () => {
939
1006
  })
940
1007
  })
941
1008
 
1009
+ describe('eth_signUserOperation', () => {
1010
+ const mockUserOperation = {
1011
+ sender: '0x1234567890123456789012345678901234567890',
1012
+ callData: '0x',
1013
+ nonce: '0x0',
1014
+ maxFeePerGas: '0x3B9ACA00',
1015
+ maxPriorityFeePerGas: '0x3B9ACA00',
1016
+ signature: '0x',
1017
+ }
1018
+
1019
+ it('should successfully handle an approved eth_signUserOperation request', async () => {
1020
+ provider.on('portal_signingRequested', () => {
1021
+ provider.emit('portal_signingApproved', {
1022
+ method: RequestMethod.eth_signUserOperation,
1023
+ params: [mockUserOperation],
1024
+ })
1025
+ })
1026
+ const result = await provider.request({
1027
+ chainId: 'eip155:1',
1028
+ method: RequestMethod.eth_signUserOperation,
1029
+ params: [mockUserOperation],
1030
+ signatureApprovalMemo: 'Test eth_signUserOperation',
1031
+ })
1032
+
1033
+ expect(mockSigningRequestedHandler).toHaveBeenCalledWith({
1034
+ method: RequestMethod.eth_signUserOperation,
1035
+ params: [mockUserOperation],
1036
+ signatureApprovalMemo: 'Test eth_signUserOperation',
1037
+ })
1038
+ expect(result).toEqual(mockSignedHash)
1039
+ expect(portal.mpc.sign).toHaveBeenCalledWith({
1040
+ chainId: 'eip155:1',
1041
+ method: RequestMethod.eth_signUserOperation,
1042
+ params: mockUserOperation,
1043
+ rpcUrl: mockRpcUrl,
1044
+ signatureApprovalMemo: 'Test eth_signUserOperation',
1045
+ traceId: 'mock-trace-id-12345',
1046
+ })
1047
+ expect(mockSignatureReceivedHandler).toHaveBeenCalledWith({
1048
+ chainId: 'eip155:1',
1049
+ method: RequestMethod.eth_signUserOperation,
1050
+ params: [mockUserOperation],
1051
+ signature: result,
1052
+ })
1053
+ })
1054
+
1055
+ it('should successfully handle a rejected eth_signUserOperation request', async () => {
1056
+ provider.on('portal_signingRequested', () => {
1057
+ provider.emit('portal_signingRejected', {
1058
+ method: RequestMethod.eth_signUserOperation,
1059
+ params: [mockUserOperation],
1060
+ })
1061
+ })
1062
+
1063
+ const result = await provider.request({
1064
+ chainId: 'eip155:1',
1065
+ method: RequestMethod.eth_signUserOperation,
1066
+ params: [mockUserOperation],
1067
+ })
1068
+ expect(mockSigningRequestedHandler).toHaveBeenCalledWith({
1069
+ method: RequestMethod.eth_signUserOperation,
1070
+ params: [mockUserOperation],
1071
+ })
1072
+ expect(result).toEqual(undefined)
1073
+ expect(mockConsoleWarn).toHaveBeenCalledWith(
1074
+ "[PortalProvider] Request for signing method 'eth_signUserOperation' could not be completed because it was not approved by the user.",
1075
+ )
1076
+ })
1077
+ })
1078
+
942
1079
  describe('eth_sign', () => {
943
1080
  it('should successfully handle an approved eth_sign request', async () => {
944
1081
  provider.on('portal_signingRequested', () => {
@@ -56,6 +56,7 @@ export enum RequestMethod {
56
56
  eth_sendTransaction = 'eth_sendTransaction',
57
57
  eth_sign = 'eth_sign',
58
58
  eth_signTransaction = 'eth_signTransaction',
59
+ eth_signUserOperation = 'eth_signUserOperation',
59
60
  eth_signTypedData = 'eth_signTypedData',
60
61
  eth_signTypedData_v3 = 'eth_signTypedData_v3',
61
62
  eth_signTypedData_v4 = 'eth_signTypedData_v4',
@@ -148,6 +149,7 @@ const signerMethods = [
148
149
  RequestMethod.eth_sendTransaction,
149
150
  RequestMethod.eth_sign,
150
151
  RequestMethod.eth_signTransaction,
152
+ RequestMethod.eth_signUserOperation,
151
153
  RequestMethod.eth_signTypedData_v3,
152
154
  RequestMethod.eth_signTypedData_v4,
153
155
  RequestMethod.personal_sign,
@@ -502,6 +504,7 @@ class Provider {
502
504
  case RequestMethod.eth_sendTransaction:
503
505
  case RequestMethod.eth_sign:
504
506
  case RequestMethod.eth_signTransaction:
507
+ case RequestMethod.eth_signUserOperation:
505
508
  case RequestMethod.eth_signTypedData_v3:
506
509
  case RequestMethod.eth_signTypedData_v4:
507
510
  case RequestMethod.personal_sign: {
@@ -701,7 +701,13 @@ export interface CustomBackupConfig {
701
701
  }
702
702
 
703
703
  // Backup method types - matches enum values from both browser and iframe packages
704
- export type BackupMethod = 'GDRIVE' | 'PASSWORD' | 'PASSKEY' | 'CUSTOM' | 'UNKNOWN'
704
+ export type BackupMethod =
705
+ | 'GDRIVE'
706
+ | 'PASSWORD'
707
+ | 'PASSKEY'
708
+ | 'CUSTOM'
709
+ | 'FIREBASE'
710
+ | 'UNKNOWN'
705
711
 
706
712
  export interface BackupConfigs {
707
713
  passwordStorage?: PasswordConfig
package/types.d.ts CHANGED
@@ -115,6 +115,11 @@ export type MessageData =
115
115
  export type ProgressCallback = (status: MpcStatus) => void | Promise<void>
116
116
  export type ValidRpcErrorCodes = 4001 | 4100 | 4200 | 4900 | 4901
117
117
 
118
+ export interface FirebaseStorageConfigOptions {
119
+ getToken: (options?: { forceRefresh?: boolean }) => Promise<string | null>
120
+ tbsHost?: string
121
+ }
122
+
118
123
  // Interfaces
119
124
 
120
125
  export interface BackupArgs extends MpcOperationArgs {