/** * @jest-environment jsdom */ import Portal, { BackupMethods, GetTransactionsOrder, PortalCurve } from '.' import { mockAddress, mockBackupConfig, mockBlockHashResponse, mockBuiltEip155Transaction, mockBuiltEip155TransactionNative, mockBuiltTronTransaction, mockBuildBatchedUserOpRequest, mockBuildBatchedUserOpResponse, mockBroadcastBatchedUserOpRequest, mockBroadcastBatchedUserOpResponse, mockCipherText, mockClientResponse, mockEip155Address, mockEjectResult, mockEjectPrivateKeysResult, mockEthRpcUrl, mockEthTransaction, mockMpcBackupResponse, mockOrgBackupShare, mockOrgBackupShares, mockQuoteArgs, mockRpcConfig, mockSendBatchUserOpRequest, mockSharesOnDevice, mockSignedHash, mockSolanaAddress, mockSolRpcUrl, mockTronAddress, } from './__mocks/constants' import mpcMock from './__mocks/portal/mpc' import providerMock from './__mocks/portal/provider' /** * context - * https://github.com/jestjs/jest/issues/4422 * https://github.com/anza-xyz/solana-pay/issues/106 * */ const originalUint8ArrayHasInstance = Uint8Array[Symbol.hasInstance] beforeAll(() => { Object.defineProperty(Uint8Array, Symbol.hasInstance, { value(potentialInstance: any) { return ( originalUint8ArrayHasInstance.call(this, potentialInstance) || Buffer.isBuffer(potentialInstance) ) }, }) }) afterAll(() => { Object.defineProperty( Uint8Array, Symbol.hasInstance, originalUint8ArrayHasInstance, ) }) describe('Portal', () => { let portal: Portal beforeEach(() => { jest.clearAllMocks() portal = new Portal({ rpcConfig: { ...mockRpcConfig, // for testing - // @ts-ignore-next-line 'incorrect-config': { test: 'test' }, }, }) portal.mpc = mpcMock portal.provider = providerMock }) describe('clearLocalWallet', () => { it('should call mpc.clearLocalWallet', async () => { await portal.clearLocalWallet() expect(portal.mpc.clearLocalWallet).toHaveBeenCalledTimes(1) }) }) describe('createWallet', () => { it('should successfully generate a wallet and call mpc.generate correctly', async () => { const mockProgressFn = jest.fn() const res = await portal.createWallet(mockProgressFn) expect(res).toBe(mockAddress) expect(portal.mpc.generate).toHaveBeenCalledTimes(1) expect(portal.mpc.generate).toHaveBeenCalledWith( { host: 'web.portalhq.io', mpcVersion: 'v6', featureFlags: {}, }, mockProgressFn, ) }) it('should successfully generate a wallet and call mpc.generate correctly with custom constructor args', async () => { portal = new Portal({ rpcConfig: mockRpcConfig, featureFlags: { isMultiBackupEnabled: true }, host: 'test-host', }) portal.mpc = mpcMock portal.provider = providerMock const mockProgressFn = jest.fn() const res = await portal.createWallet(mockProgressFn) expect(res).toBe(mockAddress) expect(portal.mpc.generate).toHaveBeenCalledTimes(1) expect(portal.mpc.generate).toHaveBeenCalledWith( { host: 'test-host', mpcVersion: 'v6', featureFlags: { isMultiBackupEnabled: true }, }, mockProgressFn, ) }) }) describe('backupWallet', () => { it('should successfully backup a wallet and call mpc.backup correctly', async () => { const mockProgressFn = jest.fn() const res = await portal.backupWallet( BackupMethods.gdrive, mockProgressFn, mockBackupConfig, ) expect(res).toBe(mockMpcBackupResponse) expect(portal.mpc.backup).toHaveBeenCalledTimes(1) expect(portal.mpc.backup).toHaveBeenCalledWith( { backupMethod: BackupMethods.gdrive, backupConfigs: mockBackupConfig, host: 'web.portalhq.io', mpcVersion: 'v6', featureFlags: {}, }, mockProgressFn, ) }) it('should successfully backup a wallet and call mpc.backup correctly with custom constructor args', async () => { portal = new Portal({ rpcConfig: mockRpcConfig, featureFlags: { isMultiBackupEnabled: true }, host: 'test-host', }) portal.mpc = mpcMock portal.provider = providerMock const mockProgressFn = jest.fn() const res = await portal.backupWallet( BackupMethods.passkey, mockProgressFn, mockBackupConfig, ) expect(res).toBe(mockMpcBackupResponse) expect(portal.mpc.backup).toHaveBeenCalledTimes(1) expect(portal.mpc.backup).toHaveBeenCalledWith( { backupMethod: BackupMethods.passkey, backupConfigs: mockBackupConfig, host: 'test-host', mpcVersion: 'v6', featureFlags: { isMultiBackupEnabled: true }, }, mockProgressFn, ) }) }) describe('generateBackupShare', () => { it('should request a passkey backup with skipStorageWrite and return cipherText and encryptionKey', async () => { const storageCallback = jest.fn().mockResolvedValue(undefined) ;(portal.mpc.backup as jest.Mock).mockResolvedValueOnce({ cipherText: mockCipherText, encryptionKey: 'manual-key', storageCallback, }) const result = await portal.generateBackupShare() expect(result).toEqual({ cipherText: mockCipherText, encryptionKey: 'manual-key', }) expect(portal.mpc.backup).toHaveBeenCalledWith( { backupMethod: BackupMethods.passkey, backupConfigs: { skipStorageWrite: true, }, host: 'web.portalhq.io', mpcVersion: 'v6', featureFlags: {}, }, expect.any(Function), ) }) it('should throw if the iframe response does not contain an encryption key', async () => { ;(portal.mpc.backup as jest.Mock).mockResolvedValueOnce({ cipherText: mockCipherText, storageCallback: jest.fn(), }) await expect(portal.generateBackupShare()).rejects.toThrow( 'Passkey backup did not return an encryption key', ) }) }) describe('registerPasskeyAndStoreEncryptionKey', () => { it('should delegate to the passkey service with computed relying party data', async () => { const passkeyServiceMock = { registerPasskeyAndStoreKey: jest.fn().mockResolvedValue(undefined), } ;(portal as any).passkeyService = passkeyServiceMock ;(portal as any).passkeyServiceDefaultDomain = 'backup.web.portalhq.io' ;(portal as any).passkeyServiceApiKey = portal.apiKey await portal.registerPasskeyAndStoreEncryptionKey( mockCipherText, 'manual-key', ) expect( passkeyServiceMock.registerPasskeyAndStoreKey, ).toHaveBeenCalledTimes(1) expect( passkeyServiceMock.registerPasskeyAndStoreKey, ).toHaveBeenCalledWith( expect.objectContaining({ customDomain: undefined, encryptionKey: 'manual-key', relyingPartyId: 'backup.web.portalhq.io', relyingPartyName: 'Portal', cipherText: mockCipherText, }), ) }) it('should throw when usePopup is requested', async () => { await expect( portal.registerPasskeyAndStoreEncryptionKey( mockCipherText, 'manual-key', { usePopup: true }, ), ).rejects.toThrow('does not support the popup flow') }) }) describe('authenticatePasskeyAndRetrieveKey', () => { it('should invoke the passkey service for direct authentication', async () => { const passkeyServiceMock = { authenticatePasskeyAndRetrieveKey: jest .fn() .mockResolvedValue('retrieved-key'), } ;(portal as any).passkeyService = passkeyServiceMock ;(portal as any).passkeyServiceDefaultDomain = 'backup.web.portalhq.io' ;(portal as any).passkeyServiceApiKey = portal.apiKey const key = await portal.authenticatePasskeyAndRetrieveKey() expect(key).toEqual('retrieved-key') expect( passkeyServiceMock.authenticatePasskeyAndRetrieveKey, ).toHaveBeenCalledWith( expect.objectContaining({ customDomain: undefined, relyingPartyId: 'backup.web.portalhq.io', relyingPartyName: 'Portal', }), ) }) it('should throw when usePopup is true', async () => { await expect( portal.authenticatePasskeyAndRetrieveKey({ usePopup: true }), ).rejects.toThrow('does not support the popup flow') }) }) describe('backupWithPasskey', () => { it('should orchestrate the direct passkey flow when usePopup is false', async () => { const directShare = { cipherText: mockCipherText, encryptionKey: 'manual-key', } const generateSpy = jest .spyOn(portal, 'generateBackupShare') .mockResolvedValue(directShare) const registerSpy = jest .spyOn(portal, 'registerPasskeyAndStoreEncryptionKey') .mockResolvedValue(undefined) const storedClientBackupShareSpy = jest .spyOn(portal, 'storedClientBackupShare') .mockResolvedValue(undefined) await portal.backupWithPasskey( { usePopup: false, customDomain: 'passkeys.wigwam.app', relyingPartyName: 'Wigwam', }, undefined, ) expect(generateSpy).toHaveBeenCalledTimes(1) expect(registerSpy).toHaveBeenCalledWith( directShare.cipherText, directShare.encryptionKey, expect.objectContaining({ customDomain: 'passkeys.wigwam.app', relyingPartyName: 'Wigwam', usePopup: false, }), ) expect(storedClientBackupShareSpy).toHaveBeenCalledWith( true, BackupMethods.passkey, ) generateSpy.mockRestore() registerSpy.mockRestore() storedClientBackupShareSpy.mockRestore() }) it('should fall back to the legacy popup flow when usePopup is true', async () => { const progress = jest.fn() await portal.backupWithPasskey({}, progress) expect(portal.mpc.backup).toHaveBeenCalledWith( { backupMethod: BackupMethods.passkey, backupConfigs: {}, host: 'web.portalhq.io', mpcVersion: 'v6', featureFlags: {}, }, progress, ) }) }) describe('recoverWallet', () => { it('should successfully recover a wallet and call mpc.recover correctly', async () => { const mockProgressFn = jest.fn() const res = await portal.recoverWallet( mockCipherText, BackupMethods.password, mockBackupConfig, mockProgressFn, ) expect(res).toBe(mockAddress) expect(portal.mpc.recover).toHaveBeenCalledTimes(1) expect(portal.mpc.recover).toHaveBeenCalledWith( { cipherText: mockCipherText, backupMethod: BackupMethods.password, backupConfigs: mockBackupConfig, host: 'web.portalhq.io', mpcVersion: 'v6', featureFlags: {}, }, mockProgressFn, ) }) it('should successfully recover a wallet and call mpc.recover correctly with custom constructor args', async () => { portal = new Portal({ rpcConfig: mockRpcConfig, featureFlags: { isMultiBackupEnabled: true }, host: 'test-host', }) portal.mpc = mpcMock portal.provider = providerMock const mockProgressFn = jest.fn() const res = await portal.recoverWallet( mockCipherText, BackupMethods.password, mockBackupConfig, mockProgressFn, ) expect(res).toBe(mockAddress) expect(portal.mpc.recover).toHaveBeenCalledTimes(1) expect(portal.mpc.recover).toHaveBeenCalledWith( { cipherText: mockCipherText, backupMethod: BackupMethods.password, backupConfigs: mockBackupConfig, host: 'test-host', mpcVersion: 'v6', featureFlags: { isMultiBackupEnabled: true }, }, mockProgressFn, ) }) }) describe('provisionWallet', () => { it('should successfully generate a wallet and call mpc.recover correctly', async () => { const mockProgressFn = jest.fn() const res = await portal.provisionWallet( mockCipherText, BackupMethods.password, mockBackupConfig, mockProgressFn, ) expect(res).toBe(mockAddress) expect(portal.mpc.recover).toHaveBeenCalledTimes(1) expect(portal.mpc.recover).toHaveBeenCalledWith( { cipherText: mockCipherText, backupMethod: BackupMethods.password, backupConfigs: mockBackupConfig, host: 'web.portalhq.io', mpcVersion: 'v6', featureFlags: {}, }, mockProgressFn, ) }) it('should successfully generate a wallet and call mpc.recover correctly with custom constructor args', async () => { portal = new Portal({ rpcConfig: mockRpcConfig, featureFlags: { isMultiBackupEnabled: true }, host: 'test-host', }) portal.mpc = mpcMock portal.provider = providerMock const mockProgressFn = jest.fn() const res = await portal.provisionWallet( mockCipherText, BackupMethods.password, mockBackupConfig, mockProgressFn, ) expect(res).toBe(mockAddress) expect(portal.mpc.recover).toHaveBeenCalledTimes(1) expect(portal.mpc.recover).toHaveBeenCalledWith( { cipherText: mockCipherText, backupMethod: BackupMethods.password, backupConfigs: mockBackupConfig, host: 'test-host', mpcVersion: 'v6', featureFlags: { isMultiBackupEnabled: true }, }, mockProgressFn, ) }) }) describe('eject', () => { it('should successfully eject a wallet when backup with portal is enabled', async () => { const res = await portal.eject(BackupMethods.password, mockBackupConfig) expect(res).toEqual(mockEjectResult) expect(portal.mpc.eject).toHaveBeenCalledTimes(1) expect(portal.mpc.eject).toHaveBeenCalledWith({ cipherText: '', backupMethod: BackupMethods.password, backupConfigs: mockBackupConfig, organizationBackupShare: '', host: 'web.portalhq.io', mpcVersion: 'v6', featureFlags: {}, }) }) it('should successfully eject a wallet when backup with portal is not enabled', async () => { portal.mpc.getClient = ( portal.mpc.getClient as jest.Mock ).mockResolvedValueOnce({ ...mockClientResponse, environment: { ...mockClientResponse.environment, backupWithPortalEnabled: false, }, }) const res = await portal.eject( BackupMethods.password, mockBackupConfig, mockOrgBackupShare, mockCipherText, ) expect(res).toEqual(mockEjectResult) expect(portal.mpc.eject).toHaveBeenCalledTimes(1) expect(portal.mpc.eject).toHaveBeenCalledWith({ cipherText: mockCipherText, organizationBackupShare: mockOrgBackupShare, backupMethod: BackupMethods.password, backupConfigs: mockBackupConfig, host: 'web.portalhq.io', mpcVersion: 'v6', featureFlags: {}, }) }) it('should error out if client could not be found', async () => { portal.mpc.getClient = ( portal.mpc.getClient as jest.Mock ).mockResolvedValueOnce(null) await expect( portal.eject( BackupMethods.password, mockBackupConfig, mockOrgBackupShare, mockCipherText, ), ).rejects.toThrowError(new Error('Client not found.')) }) it('should error if clientBackupCipherText was not supplied when backup with portal is not enabled', async () => { portal.mpc.getClient = ( portal.mpc.getClient as jest.Mock ).mockResolvedValueOnce({ ...mockClientResponse, environment: { ...mockClientResponse.environment, backupWithPortalEnabled: false, }, }) await expect( portal.eject(BackupMethods.password, mockBackupConfig), ).rejects.toThrowError( new Error('clientBackupCipherText cannot be empty string.'), ) }) it('should error if orgBackupShare was empty when backup with portal is not enabled', async () => { portal.mpc.getClient = ( portal.mpc.getClient as jest.Mock ).mockResolvedValueOnce({ ...mockClientResponse, environment: { ...mockClientResponse.environment, backupWithPortalEnabled: false, }, }) await expect( portal.eject( BackupMethods.password, mockBackupConfig, '', mockCipherText, ), ).rejects.toThrowError( new Error('orgBackupShare cannot be empty string.'), ) }) }) describe('ejectPrivateKeys', () => { it('should successfully eject a wallet when backup with portal is enabled', async () => { const res = await portal.ejectPrivateKeys( BackupMethods.password, mockBackupConfig, { SECP256K1: '', ED25519: '', }, ) expect(res).toEqual(mockEjectPrivateKeysResult) expect(portal.mpc.ejectPrivateKeys).toHaveBeenCalledTimes(1) expect(portal.mpc.ejectPrivateKeys).toHaveBeenCalledWith({ cipherText: '', backupMethod: BackupMethods.password, backupConfigs: mockBackupConfig, organizationBackupShares: { SECP256K1: '', ED25519: '', }, host: 'web.portalhq.io', mpcVersion: 'v6', featureFlags: {}, }) }) it('should successfully eject a wallet when backup with portal is not enabled', async () => { portal.mpc.getClient = ( portal.mpc.getClient as jest.Mock ).mockResolvedValueOnce({ ...mockClientResponse, environment: { ...mockClientResponse.environment, backupWithPortalEnabled: false, }, }) const res = await portal.ejectPrivateKeys( BackupMethods.password, mockBackupConfig, mockOrgBackupShares, mockCipherText, ) expect(res).toEqual(mockEjectPrivateKeysResult) expect(portal.mpc.ejectPrivateKeys).toHaveBeenCalledTimes(1) expect(portal.mpc.ejectPrivateKeys).toHaveBeenCalledWith({ cipherText: mockCipherText, organizationBackupShares: mockOrgBackupShares, backupMethod: BackupMethods.password, backupConfigs: mockBackupConfig, host: 'web.portalhq.io', mpcVersion: 'v6', featureFlags: {}, }) }) it('should error out if client could not be found', async () => { portal.mpc.getClient = ( portal.mpc.getClient as jest.Mock ).mockResolvedValueOnce(null) await expect( portal.ejectPrivateKeys( BackupMethods.password, mockBackupConfig, mockOrgBackupShares, mockCipherText, ), ).rejects.toThrowError(new Error('Client not found.')) }) it('should error if clientBackupCipherText was not supplied when backup with portal is not enabled', async () => { portal.mpc.getClient = ( portal.mpc.getClient as jest.Mock ).mockResolvedValueOnce({ ...mockClientResponse, environment: { ...mockClientResponse.environment, backupWithPortalEnabled: false, }, }) await expect( portal.ejectPrivateKeys( BackupMethods.password, mockBackupConfig, mockOrgBackupShares, ), ).rejects.toThrowError( new Error('clientBackupCipherText cannot be empty string.'), ) }) it('should error if orgBackupShare was empty when backup with portal is not enabled', async () => { portal.mpc.getClient = ( portal.mpc.getClient as jest.Mock ).mockResolvedValueOnce({ ...mockClientResponse, environment: { ...mockClientResponse.environment, backupWithPortalEnabled: false, }, }) await expect( portal.ejectPrivateKeys( BackupMethods.password, mockBackupConfig, { SECP256K1: '', ED25519: '', }, mockCipherText, ), ).rejects.toThrowError( new Error('SECP256K1 orgBackupShare cannot be empty string.'), ) }) }) describe('getEip155Address', () => { it("should return the wallet's eip155 address correctly", async () => { const res = await portal.getEip155Address() expect(res).toBe(mockEip155Address) }) }) describe('getSolanaAddress', () => { it("should return the wallet's solana address correctly", async () => { const res = await portal.getSolanaAddress() expect(res).toBe(mockSolanaAddress) }) }) describe('getTronAddress', () => { it("should return the wallet's TRON address correctly", async () => { const res = await portal.getTronAddress() expect(res).toBe(mockTronAddress) }) }) describe('doesWalletExist', () => { it('should successfully return if wallets exist', async () => { const res = await portal.doesWalletExist() expect(res).toBe(mockClientResponse.wallets.length > 0) }) it('should successfully return if wallets exist for a specific chain', async () => { expect(await portal.doesWalletExist('eip155:1')).toBe( !!mockClientResponse.metadata.namespaces['eip155'], ) expect(await portal.doesWalletExist('unsupported:chain')).toBe(false) }) }) describe('isWalletOnDevice', () => { it('should successfully check if wallet is on device', async () => { const res = await portal.isWalletOnDevice() expect(res).toBe( mockSharesOnDevice.ED25519 && mockSharesOnDevice.SECP256K1, ) }) it('should successfully check if wallet is on device for a specific chain', async () => { expect(await portal.isWalletOnDevice('eip155:1')).toBe( mockSharesOnDevice.SECP256K1, ) expect(await portal.isWalletOnDevice('unsupported:chain')).toBe(false) }) }) describe('isWalletBackedUp', () => { it('should successfully check if wallet is backed up', async () => { const res = await portal.isWalletBackedUp() expect(res).toBe(true) }) it('should successfully check if wallet is backed up for a specific chain', async () => { expect(await portal.isWalletBackedUp('eip155:1')).toBe(true) expect( await portal.isWalletBackedUp( 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', ), ).toBe(false) expect(await portal.isWalletBackedUp('unsupported:chain')).toBe(false) }) }) describe('sendSol', () => { beforeEach(() => { portal.address = mockSolanaAddress }) it('should successfully send sol', async () => { portal.provider.request = (portal.provider.request as jest.Mock) .mockResolvedValueOnce(mockBlockHashResponse) .mockResolvedValueOnce(mockSignedHash) const result = await portal.sendSol({ chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', to: '9G2CRh8pzicbRkiNLh8Xsp2DEP28UhwtHSyqetGYTCWD', lamports: 10000, }) expect(result).toBe(mockSignedHash) expect(portal.provider.request).toHaveBeenCalledTimes(2) expect((portal.provider.request as jest.Mock).mock.calls[0]).toEqual([ { chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', method: 'getLatestBlockhash', params: [], }, ]) expect((portal.provider.request as jest.Mock).mock.calls[1]).toEqual([ { chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', method: 'sol_signAndSendTransaction', params: [ { message: { accountKeys: [ mockSolanaAddress, '9G2CRh8pzicbRkiNLh8Xsp2DEP28UhwtHSyqetGYTCWD', '11111111111111111111111111111111', ], header: { numReadonlySignedAccounts: 0, numReadonlyUnsignedAccounts: 1, numRequiredSignatures: 1, }, instructions: [ { accounts: [0, 1], // @solana/web3.js/src/programs/system.ts > .transfer > encodeData data: '3Bxs43ZMjSRQLs6o', programIdIndex: 2, }, ], recentBlockhash: mockBlockHashResponse.value.blockhash, }, signatures: null, }, ], }, ]) }) it('should throw an error if the chainId is invalid', async () => { await expect( portal.sendSol({ chainId: 'eip155:1', to: '9G2CRh8pzicbRkiNLh8Xsp2DEP28UhwtHSyqetGYTCWD', lamports: 10000, }), ).rejects.toThrow( new Error( '[Portal] Invalid chainId. Please provide a chainId that starts with "solana:"', ), ) }) it('should throw an error if the to address is invalid', async () => { await expect( portal.sendSol({ chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', to: 'test', lamports: 10000, }), ).rejects.toThrow( new Error( '[Portal] Invalid "to" Solana address provided (not a valid public key)', ), ) }) it('should throw an error if the lamports arg is invalid', async () => { await expect( portal.sendSol({ chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', to: '9G2CRh8pzicbRkiNLh8Xsp2DEP28UhwtHSyqetGYTCWD', lamports: -32, }), ).rejects.toThrow( new Error( '[Portal] Invalid lamports amount, must be a positive number greater than 0', ), ) }) it('should throw an error if the latest blockhash could not be fetched', async () => { portal.provider.request = ( portal.provider.request as jest.Mock ).mockResolvedValueOnce({ error: 'test' }) await expect( portal.sendSol({ chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', to: '9G2CRh8pzicbRkiNLh8Xsp2DEP28UhwtHSyqetGYTCWD', lamports: 10000, }), ).rejects.toThrow( new Error('[Portal] Failed to get most recent blockhash'), ) }) it('should throw an error if rpc url was empty for the chain', async () => { portal = new Portal({ rpcConfig: { 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1': '', }, }) portal.address = mockSolanaAddress portal.mpc = mpcMock portal.provider = providerMock portal.provider.request = ( portal.provider.request as jest.Mock ).mockResolvedValueOnce(mockBlockHashResponse) await expect( portal.sendSol({ chainId: 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1', to: '9G2CRh8pzicbRkiNLh8Xsp2DEP28UhwtHSyqetGYTCWD', lamports: 10000, }), ).rejects.toThrow( new Error('[Portal] No RPC endpoint configured for chainId'), ) }) it("should throw an error if client's solana address could not be fetched", async () => { portal.provider.request = ( portal.provider.request as jest.Mock ).mockResolvedValueOnce(mockBlockHashResponse) portal.mpc.getClient = ( portal.mpc.getClient as jest.Mock ).mockResolvedValueOnce(null) await expect( portal.sendSol({ chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', to: '9G2CRh8pzicbRkiNLh8Xsp2DEP28UhwtHSyqetGYTCWD', lamports: 10000, }), ).rejects.toThrow(new Error('[Portal] Failed to get Solana address')) }) it("should throw an error if txn hash wasn't returned from the provider", async () => { portal.provider.request = (portal.provider.request as jest.Mock) .mockResolvedValueOnce(mockBlockHashResponse) .mockResolvedValueOnce(null) await expect( portal.sendSol({ chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', to: '9G2CRh8pzicbRkiNLh8Xsp2DEP28UhwtHSyqetGYTCWD', lamports: 10000, }), ).rejects.toThrow(new Error('[Portal] Failed to send Solana transaction')) }) }) describe('sendEth', () => { it('should correctly call provider.request', async () => { portal.address = mockAddress await portal.sendEth({ chainId: 'eip155:1', to: 'test', value: '42', }) expect(portal.provider.request).toHaveBeenCalledTimes(1) expect(portal.provider.request).toHaveBeenCalledWith({ chainId: 'eip155:1', method: 'eth_sendTransaction', params: [ { from: mockAddress, to: 'test', value: '42', }, ], }) }) }) describe('sendAsset (TRON)', () => { const tronChainId = 'tron:nile' const tronRpcUrl = 'https://test-tron-rpc' beforeEach(() => { portal = new Portal({ rpcConfig: { ...mockRpcConfig, [tronChainId]: tronRpcUrl, }, }) portal.mpc = mpcMock portal.provider = providerMock }) it('should build the transaction and sign it via tron_sendTransaction', async () => { ;(portal.mpc.buildTransaction as jest.Mock).mockResolvedValueOnce( mockBuiltTronTransaction, ) ;(portal.provider.request as jest.Mock).mockResolvedValueOnce( mockSignedHash, ) const result = await portal.sendAsset(tronChainId, { to: mockBuiltTronTransaction.metadata.toAddress, token: 'NATIVE', amount: '1', }) expect(result).toBe(mockSignedHash) expect(portal.mpc.buildTransaction).toHaveBeenCalledWith( tronChainId, mockBuiltTronTransaction.metadata.toAddress, 'NATIVE', '1', expect.any(String), ) expect(portal.provider.request).toHaveBeenCalledWith( expect.objectContaining({ chainId: tronChainId, method: 'tron_sendTransaction', params: [mockBuiltTronTransaction.transaction.id], }), ) }) it('should propagate build errors', async () => { ;(portal.mpc.buildTransaction as jest.Mock).mockRejectedValueOnce( new Error('Build failed'), ) await expect( portal.sendAsset(tronChainId, { to: mockBuiltTronTransaction.metadata.toAddress, token: 'NATIVE', amount: '1', }), ).rejects.toThrow('Failed to send asset: Build failed') }) it('should propagate signing errors', async () => { ;(portal.mpc.buildTransaction as jest.Mock).mockResolvedValueOnce( mockBuiltTronTransaction, ) ;(portal.provider.request as jest.Mock).mockRejectedValueOnce( new Error('Signing failed'), ) await expect( portal.sendAsset(tronChainId, { to: mockBuiltTronTransaction.metadata.toAddress, token: 'NATIVE', amount: '1', }), ).rejects.toThrow('Failed to send asset: Signing failed') }) }) describe('getBalances', () => { it('should correctly call mpc.getBalances', async () => { await portal.getBalances('eip155:1') expect(portal.mpc.getBalances).toHaveBeenCalledTimes(1) expect(portal.mpc.getBalances).toHaveBeenCalledWith('eip155:1') }) }) describe('getClient', () => { it('should correctly call mpc.getClient', async () => { await portal.getClient() expect(portal.mpc.getClient).toHaveBeenCalledTimes(1) expect(portal.mpc.getClient).toHaveBeenCalledWith() }) }) describe('getNFTs', () => { it('should correctly call mpc.getNFTs', async () => { await portal.getNFTs('eip155:1') expect(portal.mpc.getNFTs).toHaveBeenCalledTimes(1) expect(portal.mpc.getNFTs).toHaveBeenCalledWith('eip155:1') }) }) describe('getTransactions', () => { it('should correctly call mpc.getTransactions', async () => { await portal.getTransactions('eip155:1', 1, 10, GetTransactionsOrder.DESC) expect(portal.mpc.getTransactions).toHaveBeenCalledTimes(1) expect(portal.mpc.getTransactions).toHaveBeenCalledWith( 'eip155:1', 1, 10, GetTransactionsOrder.DESC, ) }) }) describe('simulateTransaction', () => { it('should correctly call mpc.simulateTransaction', async () => { await portal.simulateTransaction('eip155:1', mockEthTransaction) expect(portal.mpc.simulateTransaction).toHaveBeenCalledTimes(1) expect(portal.mpc.simulateTransaction).toHaveBeenCalledWith( mockEthTransaction, 'eip155:1', ) }) }) describe('evaluateTransaction', () => { it('should correctly call mpc.evaluateTransaction', async () => { await portal.evaluateTransaction( 'eip155:1', mockEthTransaction, 'validation', ) expect(portal.mpc.evaluateTransaction).toHaveBeenCalledTimes(1) expect(portal.mpc.evaluateTransaction).toHaveBeenCalledWith( 'eip155:1', mockEthTransaction, 'validation', ) }) }) describe('buildTransaction', () => { it('should correctly call mpc.buildTransaction', async () => { await portal.buildTransaction('eip155:1', mockAddress, 'USDC', '1') expect(portal.mpc.buildTransaction).toHaveBeenCalledTimes(1) expect(portal.mpc.buildTransaction).toHaveBeenCalledWith( 'eip155:1', mockAddress, 'USDC', '1', undefined, ) }) }) describe('getNFTAssets', () => { it('should correctly call mpc.getNFTAssets', async () => { await portal.getNFTAssets('eip155:1') expect(portal.mpc.getNFTAssets).toHaveBeenCalledTimes(1) expect(portal.mpc.getNFTAssets).toHaveBeenCalledWith('eip155:1') }) }) describe('getAssets', () => { it('should correctly call mpc.getAssets', async () => { await portal.getAssets('eip155:1') expect(portal.mpc.getAssets).toHaveBeenCalledTimes(1) expect(portal.mpc.getAssets).toHaveBeenCalledWith('eip155:1', false) }) }) describe('getQuote', () => { it('should correctly call mpc.getQuote', async () => { await portal.getQuote('test', mockQuoteArgs, 'eip155:1') expect(portal.mpc.getQuote).toHaveBeenCalledTimes(1) expect(portal.mpc.getQuote).toHaveBeenCalledWith( 'eip155:1', mockQuoteArgs, 'test', ) }) }) describe('getSources', () => { it('should correctly call mpc.getSources', async () => { await portal.getSources('test', 'eip155:1') expect(portal.mpc.getSources).toHaveBeenCalledTimes(1) expect(portal.mpc.getSources).toHaveBeenCalledWith('eip155:1', 'test') }) }) describe('buildBatchedUserOp', () => { it('should correctly call mpc.accountAbstractionBuildBatchedUserOp', async () => { await portal.buildBatchedUserOp(mockBuildBatchedUserOpRequest) expect( portal.mpc.accountAbstractionBuildBatchedUserOp, ).toHaveBeenCalledTimes(1) expect( portal.mpc.accountAbstractionBuildBatchedUserOp, ).toHaveBeenCalledWith(mockBuildBatchedUserOpRequest) }) }) describe('broadcastBatchedUserOp', () => { it('should correctly call mpc.accountAbstractionBroadcastBatchedUserOp', async () => { await portal.broadcastBatchedUserOp(mockBroadcastBatchedUserOpRequest) expect( portal.mpc.accountAbstractionBroadcastBatchedUserOp, ).toHaveBeenCalledTimes(1) expect( portal.mpc.accountAbstractionBroadcastBatchedUserOp, ).toHaveBeenCalledWith(mockBroadcastBatchedUserOpRequest) }) }) describe('sendBatchUserOp', () => { it('should build, sign, and broadcast a batched UserOperation', async () => { const result = await portal.sendBatchUserOp(mockSendBatchUserOpRequest) expect(result).toEqual( expect.objectContaining({ data: expect.objectContaining({ userOpHash: expect.any(String) }), metadata: expect.objectContaining({ chainId: expect.any(String) }), }), ) // buildTransaction called once per transaction descriptor expect(portal.mpc.buildTransaction).toHaveBeenCalledTimes(2) expect(portal.mpc.buildTransaction).toHaveBeenCalledWith( mockSendBatchUserOpRequest.chain, mockSendBatchUserOpRequest.transactions[0].to, mockSendBatchUserOpRequest.transactions[0].token, mockSendBatchUserOpRequest.transactions[0].value, expect.any(String), ) expect(portal.mpc.buildTransaction).toHaveBeenCalledWith( mockSendBatchUserOpRequest.chain, mockSendBatchUserOpRequest.transactions[1].to, mockSendBatchUserOpRequest.transactions[1].token, mockSendBatchUserOpRequest.transactions[1].value, expect.any(String), ) // buildBatchedUserOp called with ERC-20 call shape (no value field) and the shared traceId expect( portal.mpc.accountAbstractionBuildBatchedUserOp, ).toHaveBeenCalledTimes(1) expect( portal.mpc.accountAbstractionBuildBatchedUserOp, ).toHaveBeenCalledWith( { chain: mockSendBatchUserOpRequest.chain, calls: [ { to: mockBuiltEip155Transaction.transaction.to, data: mockBuiltEip155Transaction.transaction.data, }, { to: mockBuiltEip155Transaction.transaction.to, data: mockBuiltEip155Transaction.transaction.data, }, ], }, expect.any(String), ) // rawSign called with SECP256K1 and the userOpHash with 0x prefix stripped expect(portal.mpc.rawSign).toHaveBeenCalledTimes(1) expect(portal.mpc.rawSign).toHaveBeenCalledWith( PortalCurve.SECP256K1, mockBuildBatchedUserOpResponse.data.userOpHash.slice(2), { signatureApprovalMemo: undefined, traceId: expect.any(String) }, ) // broadcastBatchedUserOp called with the built userOperation and signature, and the shared traceId expect( portal.mpc.accountAbstractionBroadcastBatchedUserOp, ).toHaveBeenCalledTimes(1) expect( portal.mpc.accountAbstractionBroadcastBatchedUserOp, ).toHaveBeenCalledWith( { chain: mockSendBatchUserOpRequest.chain, userOperation: mockBuildBatchedUserOpResponse.data.userOperation, signature: mockSignedHash, }, expect.any(String), ) }) it('should propagate the same traceId to buildBatchedUserOp and broadcastBatchedUserOp', async () => { await portal.sendBatchUserOp(mockSendBatchUserOpRequest) const buildTraceId = ( portal.mpc.accountAbstractionBuildBatchedUserOp as jest.Mock ).mock.calls[0][1] const broadcastTraceId = ( portal.mpc.accountAbstractionBroadcastBatchedUserOp as jest.Mock ).mock.calls[0][1] expect(typeof buildTraceId).toBe('string') expect(buildTraceId).toEqual(broadcastTraceId) }) it('should include value in the call for native ETH transfers', async () => { ;(portal.mpc.buildTransaction as jest.Mock).mockResolvedValueOnce( mockBuiltEip155TransactionNative, ) await portal.sendBatchUserOp({ chain: 'eip155:1', transactions: [{ token: 'ETH', value: '0.1', to: '0x1111111111111111111111111111111111111111' }], }) expect( portal.mpc.accountAbstractionBuildBatchedUserOp, ).toHaveBeenCalledWith( { chain: 'eip155:1', calls: [ { to: mockBuiltEip155TransactionNative.transaction.to, data: '0x', value: mockBuiltEip155TransactionNative.metadata.rawAmount, }, ], }, expect.any(String), ) }) it('should not include value in the call for ERC-20 transfers', async () => { await portal.sendBatchUserOp({ chain: 'eip155:1', transactions: [{ token: 'USDC', value: '1.0', to: '0x1111111111111111111111111111111111111111' }], }) const calls = ( portal.mpc.accountAbstractionBuildBatchedUserOp as jest.Mock ).mock.calls[0][0].calls expect(calls[0]).not.toHaveProperty('value') }) it('should forward signatureApprovalMemo to rawSign', async () => { await portal.sendBatchUserOp({ ...mockSendBatchUserOpRequest, signatureApprovalMemo: 'approve this batch', }) expect(portal.mpc.rawSign).toHaveBeenCalledWith( PortalCurve.SECP256K1, expect.any(String), { signatureApprovalMemo: 'approve this batch', traceId: expect.any(String) }, ) }) it('should throw if chain is not eip155', async () => { await expect( portal.sendBatchUserOp({ ...mockSendBatchUserOpRequest, chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', }), ).rejects.toThrow( '[Portal.sendBatchUserOp] UserOperations are only supported on EIP-155 (EVM) chains', ) }) it('should throw if transactions array is empty', async () => { await expect( portal.sendBatchUserOp({ ...mockSendBatchUserOpRequest, transactions: [], }), ).rejects.toThrow( '[Portal.sendBatchUserOp] transactions must contain at least one transaction', ) }) it('should throw with index context if buildTransaction fails for transaction 0', async () => { ;(portal.mpc.buildTransaction as jest.Mock).mockRejectedValueOnce( new Error('Network error'), ) await expect( portal.sendBatchUserOp({ chain: 'eip155:1', transactions: [{ token: 'USDC', value: '1.0', to: '0x1111111111111111111111111111111111111111' }], }), ).rejects.toThrow( '[Portal.sendBatchUserOp] Failed to build call for transaction at index 0: Network error', ) }) it('should throw with index context if buildTransaction fails for transaction 1', async () => { ;(portal.mpc.buildTransaction as jest.Mock) .mockResolvedValueOnce(mockBuiltEip155Transaction) .mockRejectedValueOnce(new Error('Network error')) await expect( portal.sendBatchUserOp(mockSendBatchUserOpRequest), ).rejects.toThrow( '[Portal.sendBatchUserOp] Failed to build call for transaction at index 1: Network error', ) }) it('should throw if buildBatchedUserOp fails', async () => { ;( portal.mpc.accountAbstractionBuildBatchedUserOp as jest.Mock ).mockRejectedValueOnce(new Error('UserOp build failed')) await expect( portal.sendBatchUserOp(mockSendBatchUserOpRequest), ).rejects.toThrow( '[Portal.sendBatchUserOp] Failed to build UserOperation: UserOp build failed', ) }) it('should throw if rawSign fails', async () => { ;(portal.mpc.rawSign as jest.Mock).mockRejectedValueOnce( new Error('Signing failed'), ) await expect( portal.sendBatchUserOp(mockSendBatchUserOpRequest), ).rejects.toThrow( '[Portal.sendBatchUserOp] Failed to sign userOpHash: Signing failed', ) }) it('should throw if broadcastBatchedUserOp fails', async () => { ;( portal.mpc.accountAbstractionBroadcastBatchedUserOp as jest.Mock ).mockRejectedValueOnce(new Error('Broadcast failed')) await expect( portal.sendBatchUserOp(mockSendBatchUserOpRequest), ).rejects.toThrow( '[Portal.sendBatchUserOp] Failed to broadcast UserOperation: Broadcast failed', ) }) }) describe('sendBatchedAssets', () => { const feeRecipient = '0x2222222222222222222222222222222222222222' const baseRequest = { chain: 'eip155:1', transactions: [ { token: 'USDC', value: '1.0', to: '0x1111111111111111111111111111111111111111', }, ], } it('should build twice, convert gas to a fee, sign, and broadcast', async () => { const convertGasToFeeAmount = jest.fn().mockResolvedValue('0.5') const result = await portal.sendBatchedAssets({ ...baseRequest, gasReimbursement: { feeToken: 'USDC', feeRecipient, convertGasToFeeAmount }, }) expect(result).toEqual(mockBroadcastBatchedUserOpResponse) // Estimation pass + final pass = 2 builds. expect( portal.mpc.accountAbstractionBuildBatchedUserOp, ).toHaveBeenCalledTimes(2) // buildTransaction: 1 user tx + 1 placeholder fee + 1 real fee = 3. expect(portal.mpc.buildTransaction).toHaveBeenCalledTimes(3) // Conversion called once with gasCostWei = totalGas(100000) * maxFeePerGas(1e9). expect(convertGasToFeeAmount).toHaveBeenCalledTimes(1) expect(convertGasToFeeAmount).toHaveBeenCalledWith(BigInt('100000000000000')) // Placeholder fee call built with the default '0.01'; real fee call with '0.5'. expect(portal.mpc.buildTransaction).toHaveBeenCalledWith( 'eip155:1', feeRecipient, 'USDC', '0.01', expect.any(String), ) expect(portal.mpc.buildTransaction).toHaveBeenCalledWith( 'eip155:1', feeRecipient, 'USDC', '0.5', expect.any(String), ) expect(portal.mpc.rawSign).toHaveBeenCalledTimes(1) expect( portal.mpc.accountAbstractionBroadcastBatchedUserOp, ).toHaveBeenCalledTimes(1) }) it('should apply bufferBps to the gas cost before conversion', async () => { const convertGasToFeeAmount = jest.fn().mockResolvedValue('0.6') await portal.sendBatchedAssets({ ...baseRequest, gasReimbursement: { feeToken: 'USDC', feeRecipient, convertGasToFeeAmount, bufferBps: 1000, // +10% }, }) // 1e14 * 11000 / 10000 = 1.1e14 expect(convertGasToFeeAmount).toHaveBeenCalledWith(BigInt('110000000000000')) }) it('should throw if the build response is missing estimatedGasCostWei', async () => { const noGasCost = { ...mockBuildBatchedUserOpResponse, metadata: { chainId: 'eip155:1' }, } ;( portal.mpc.accountAbstractionBuildBatchedUserOp as jest.Mock ).mockResolvedValueOnce(noGasCost) const convertGasToFeeAmount = jest.fn() await expect( portal.sendBatchedAssets({ ...baseRequest, gasReimbursement: { feeToken: 'USDC', feeRecipient, convertGasToFeeAmount }, }), ).rejects.toThrow( '[Portal.sendBatchedAssets] build response is missing metadata.estimatedGasCostWei', ) // Conversion should never run if we can't determine the gas cost. expect(convertGasToFeeAmount).not.toHaveBeenCalled() }) it('should throw if the estimated gas cost is 0 (e.g. Ultra Relay zero-fee path)', async () => { const zeroCost = { ...mockBuildBatchedUserOpResponse, metadata: { chainId: 'eip155:1', totalGas: '2000000', maxFeePerGas: '0', estimatedGasCostWei: '0', }, } ;( portal.mpc.accountAbstractionBuildBatchedUserOp as jest.Mock ).mockResolvedValueOnce(zeroCost) const convertGasToFeeAmount = jest.fn() await expect( portal.sendBatchedAssets({ ...baseRequest, gasReimbursement: { feeToken: 'USDC', feeRecipient, convertGasToFeeAmount }, }), ).rejects.toThrow( '[Portal.sendBatchedAssets] Estimated gas cost is 0', ) expect(convertGasToFeeAmount).not.toHaveBeenCalled() }) it('should reuse the same traceId across both builds', async () => { const convertGasToFeeAmount = jest.fn().mockResolvedValue('0.5') await portal.sendBatchedAssets({ ...baseRequest, gasReimbursement: { feeToken: 'USDC', feeRecipient, convertGasToFeeAmount }, }) const calls = ( portal.mpc.accountAbstractionBuildBatchedUserOp as jest.Mock ).mock.calls expect(typeof calls[0][1]).toBe('string') expect(calls[0][1]).toEqual(calls[1][1]) }) it('should throw if chain is not eip155', async () => { await expect( portal.sendBatchedAssets({ chain: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', transactions: baseRequest.transactions, gasReimbursement: { feeToken: 'USDC', feeRecipient, convertGasToFeeAmount: () => '1', }, }), ).rejects.toThrow( '[Portal.sendBatchedAssets] UserOperations are only supported on EIP-155 (EVM) chains', ) }) it('should throw if transactions is empty', async () => { await expect( portal.sendBatchedAssets({ chain: 'eip155:1', transactions: [], gasReimbursement: { feeToken: 'USDC', feeRecipient, convertGasToFeeAmount: () => '1', }, }), ).rejects.toThrow( '[Portal.sendBatchedAssets] transactions must contain at least one transaction', ) }) it('should throw if convertGasToFeeAmount is missing', async () => { await expect( portal.sendBatchedAssets({ ...baseRequest, gasReimbursement: { feeToken: 'USDC', feeRecipient } as never, }), ).rejects.toThrow( '[Portal.sendBatchedAssets] gasReimbursement.convertGasToFeeAmount (a function) is required', ) }) it('should throw if feeRecipient is not a valid EVM address', async () => { await expect( portal.sendBatchedAssets({ ...baseRequest, gasReimbursement: { feeToken: 'USDC', feeRecipient: '0xnope', convertGasToFeeAmount: () => '1', }, }), ).rejects.toThrow( '[Portal.sendBatchedAssets] Invalid gasReimbursement.feeRecipient', ) }) it('should surface an error thrown by the conversion callback', async () => { const convertGasToFeeAmount = jest .fn() .mockRejectedValue(new Error('rate unavailable')) await expect( portal.sendBatchedAssets({ ...baseRequest, gasReimbursement: { feeToken: 'USDC', feeRecipient, convertGasToFeeAmount }, }), ).rejects.toThrow( '[Portal.sendBatchedAssets] gasReimbursement.convertGasToFeeAmount threw: rate unavailable', ) }) }) describe('storedClientBackupShare', () => { it('should correctly call mpc.storedClientBackupShare', async () => { await portal.storedClientBackupShare(true, BackupMethods.password) expect(portal.mpc.storedClientBackupShare).toHaveBeenCalledTimes(1) expect(portal.mpc.storedClientBackupShare).toHaveBeenCalledWith( true, BackupMethods.password, ) }) }) describe('Logging', () => { it('should default log level to "none"', () => { expect(portal.getLogLevel()).toBe('none') }) it('should use logLevel from constructor options', () => { const p = new Portal({ rpcConfig: mockRpcConfig, logLevel: 'debug' }) expect(p.getLogLevel()).toBe('debug') }) it('should update log level when setLogLevel is called', () => { expect(portal.getLogLevel()).toBe('none') portal.setLogLevel('warn') expect(portal.getLogLevel()).toBe('warn') portal.setLogLevel('error') expect(portal.getLogLevel()).toBe('error') }) }) describe('getRpcUrl', () => { it('should return the correct rpc url for the given chainId', () => { expect(portal.getRpcUrl('eip155:1')).toEqual(mockEthRpcUrl) expect( portal.getRpcUrl('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), ).toEqual(mockSolRpcUrl) }) it('should throw an error if the chainId is not provided', () => { expect(() => portal.getRpcUrl()).toThrow( new Error( '[Portal] No chainId provided. Please provide a chainId to get the RPC endpoint', ), ) }) it('should throw an error if the chainId is not present in the rpc config', () => { expect(() => portal.getRpcUrl('test')).toThrow( new Error('[Portal] No RPC endpoint configured for chainId: test'), ) }) it('should throw an error if the chainId is not present in the rpc config', () => { expect(() => portal.getRpcUrl('incorrect-config')).toThrow( new Error( '[Portal] Could not find a valid rpcConfig entry for chainId: incorrect-config', ), ) }) it('should use rpcConfig for getRpcUrl when iframeRpcConfig is also set', () => { const iframeRpcConfig = { 'eip155:1': 'http://localhost:9999/rpc/v1/eip155/1', } portal = new Portal({ rpcConfig: mockRpcConfig, iframeRpcConfig, }) portal.mpc = mpcMock portal.provider = providerMock expect(portal.getRpcUrl('eip155:1')).toEqual(mockEthRpcUrl) expect(portal.iframeRpcConfig).toEqual(iframeRpcConfig) }) }) })