/** * @jest-environment jsdom */ import Delegations from '.' import Mpc from '../../mpc' import portalMock from '../../__mocks/portal/portal' import { mockHost, mockEVMApproveRequest, mockEVMApproveResponse, mockEVMRevokeRequest, mockEVMRevokeResponse, mockGetStatusRequest, mockGetStatusResponse, mockTransferFromRequest, mockTransferFromResponse, } from '../../__mocks__/delegations' describe('Delegations', () => { let delegations: Delegations let mpc: Mpc beforeEach(() => { jest.clearAllMocks() portalMock.host = mockHost mpc = new Mpc({ portal: portalMock, }) delegations = new Delegations({ mpc }) }) describe('approve', () => { it('should call mpc.delegationsApprove with the correct arguments', async () => { const spy = jest .spyOn(mpc, 'delegationsApprove') .mockResolvedValue(mockEVMApproveResponse) const result = await delegations.approve(mockEVMApproveRequest) expect(spy).toHaveBeenCalledWith(mockEVMApproveRequest) expect(result).toEqual(mockEVMApproveResponse) }) it('should propagate errors from mpc.delegationsApprove', async () => { const error = new Error('Test error') jest.spyOn(mpc, 'delegationsApprove').mockRejectedValue(error) await expect(delegations.approve(mockEVMApproveRequest)).rejects.toThrow( 'Test error', ) }) }) describe('revoke', () => { it('should call mpc.delegationsRevoke with the correct arguments', async () => { const spy = jest .spyOn(mpc, 'delegationsRevoke') .mockResolvedValue(mockEVMRevokeResponse) const result = await delegations.revoke(mockEVMRevokeRequest) expect(spy).toHaveBeenCalledWith(mockEVMRevokeRequest) expect(result).toEqual(mockEVMRevokeResponse) }) it('should propagate errors from mpc.delegationsRevoke', async () => { const error = new Error('Test error') jest.spyOn(mpc, 'delegationsRevoke').mockRejectedValue(error) await expect(delegations.revoke(mockEVMRevokeRequest)).rejects.toThrow( 'Test error', ) }) }) describe('getStatus', () => { it('should call mpc.delegationsGetStatus with the correct arguments', async () => { const spy = jest .spyOn(mpc, 'delegationsGetStatus') .mockResolvedValue(mockGetStatusResponse) const result = await delegations.getStatus(mockGetStatusRequest) expect(spy).toHaveBeenCalledWith(mockGetStatusRequest) expect(result).toEqual(mockGetStatusResponse) }) it('should propagate errors from mpc.delegationsGetStatus', async () => { const error = new Error('Test error') jest.spyOn(mpc, 'delegationsGetStatus').mockRejectedValue(error) await expect(delegations.getStatus(mockGetStatusRequest)).rejects.toThrow( 'Test error', ) }) }) describe('transferFrom', () => { it('should call mpc.delegationsTransferFrom with the correct arguments', async () => { const spy = jest .spyOn(mpc, 'delegationsTransferFrom') .mockResolvedValue(mockTransferFromResponse) const result = await delegations.transferFrom(mockTransferFromRequest) expect(spy).toHaveBeenCalledWith(mockTransferFromRequest) expect(result).toEqual(mockTransferFromResponse) }) it('should propagate errors from mpc.delegationsTransferFrom', async () => { const error = new Error('Test error') jest.spyOn(mpc, 'delegationsTransferFrom').mockRejectedValue(error) await expect( delegations.transferFrom(mockTransferFromRequest), ).rejects.toThrow('Test error') }) }) describe('approveAndSubmit, revokeAndSubmit, transferAndSubmit', () => { const mockSignAndSend = jest.fn() beforeEach(() => { mockSignAndSend.mockResolvedValue('0xtxhash') }) it('throws when signAndSendTransaction is not configured', async () => { const d = new Delegations({ mpc }) await expect(d.approveAndSubmit(mockEVMApproveRequest)).rejects.toThrow( '[Delegations] No signer configured. Call setSignAndSendTransaction() on the instance or pass signAndSendTransaction in options.', ) await expect(d.revokeAndSubmit(mockEVMRevokeRequest)).rejects.toThrow( '[Delegations] No signer configured. Call setSignAndSendTransaction() on the instance or pass signAndSendTransaction in options.', ) await expect(d.transferAndSubmit(mockTransferFromRequest)).rejects.toThrow( '[Delegations] No signer configured. Call setSignAndSendTransaction() on the instance or pass signAndSendTransaction in options.', ) }) it('approveAndSubmit calls MPC then signAndSend per transaction', async () => { jest.spyOn(mpc, 'delegationsApprove').mockResolvedValue(mockEVMApproveResponse) const d = new Delegations({ mpc, signAndSendTransaction: mockSignAndSend, }) const result = await d.approveAndSubmit(mockEVMApproveRequest) expect(mpc.delegationsApprove).toHaveBeenCalledWith(mockEVMApproveRequest) expect(mockSignAndSend).toHaveBeenCalledTimes(1) expect(mockSignAndSend).toHaveBeenCalledWith( mockEVMApproveResponse.transactions![0], mockEVMApproveRequest.chain, ) expect(result).toEqual({ hashes: ['0xtxhash'] }) }) it('revokeAndSubmit calls delegationsRevoke then signAndSend per transaction', async () => { jest.spyOn(mpc, 'delegationsRevoke').mockResolvedValue(mockEVMRevokeResponse) const d = new Delegations({ mpc, signAndSendTransaction: mockSignAndSend, }) const result = await d.revokeAndSubmit(mockEVMRevokeRequest) expect(mpc.delegationsRevoke).toHaveBeenCalledWith(mockEVMRevokeRequest) expect(mockSignAndSend).toHaveBeenCalledTimes(1) expect(mockSignAndSend).toHaveBeenCalledWith( mockEVMRevokeResponse.transactions![0], mockEVMRevokeRequest.chain, ) expect(result).toEqual({ hashes: ['0xtxhash'] }) }) it('transferAndSubmit calls delegationsTransferFrom then signAndSend per transaction', async () => { jest .spyOn(mpc, 'delegationsTransferFrom') .mockResolvedValue(mockTransferFromResponse) const d = new Delegations({ mpc, signAndSendTransaction: mockSignAndSend, }) const result = await d.transferAndSubmit(mockTransferFromRequest) expect(mpc.delegationsTransferFrom).toHaveBeenCalledWith( mockTransferFromRequest, ) expect(mockSignAndSend).toHaveBeenCalledTimes(1) expect(mockSignAndSend).toHaveBeenCalledWith( mockTransferFromResponse.transactions![0], mockTransferFromRequest.chain, ) expect(result).toEqual({ hashes: ['0xtxhash'] }) }) it('uses encodedTransactions when transactions is empty', async () => { const response = { transactions: [], encodedTransactions: ['solTxPayload'], metadata: mockEVMApproveResponse.metadata, } jest.spyOn(mpc, 'delegationsApprove').mockResolvedValue(response) const d = new Delegations({ mpc, signAndSendTransaction: mockSignAndSend, }) await d.approveAndSubmit(mockEVMApproveRequest) expect(mockSignAndSend).toHaveBeenCalledWith( 'solTxPayload', mockEVMApproveRequest.chain, ) }) it('invokes onProgress for each transaction', async () => { jest.spyOn(mpc, 'delegationsApprove').mockResolvedValue(mockEVMApproveResponse) const onProgress = jest.fn() const d = new Delegations({ mpc, signAndSendTransaction: mockSignAndSend, }) await d.approveAndSubmit(mockEVMApproveRequest, { onProgress }) expect(onProgress).toHaveBeenCalledTimes(2) expect(onProgress).toHaveBeenNthCalledWith(1, { step: 'signing', index: 0, total: 1, }) expect(onProgress).toHaveBeenNthCalledWith(2, { step: 'submitted', index: 0, total: 1, hash: '0xtxhash', }) }) it('throws when response has no transactions', async () => { jest.spyOn(mpc, 'delegationsApprove').mockResolvedValue({ metadata: mockEVMApproveResponse.metadata, }) const d = new Delegations({ mpc, signAndSendTransaction: mockSignAndSend, }) await expect(d.approveAndSubmit(mockEVMApproveRequest)).rejects.toThrow( 'No transactions in delegation response.', ) expect(mockSignAndSend).not.toHaveBeenCalled() }) it('submits multiple EVM transactions in order', async () => { const txA = { ...( mockEVMApproveResponse.transactions as NonNullable< typeof mockEVMApproveResponse.transactions > )[0]! } const txB = { ...txA, data: '0xbbbb' } jest.spyOn(mpc, 'delegationsApprove').mockResolvedValue({ ...mockEVMApproveResponse, transactions: [txA, txB], }) const d = new Delegations({ mpc, signAndSendTransaction: mockSignAndSend, }) mockSignAndSend.mockResolvedValueOnce('0xh1').mockResolvedValueOnce('0xh2') const result = await d.approveAndSubmit(mockEVMApproveRequest) expect(mockSignAndSend).toHaveBeenCalledTimes(2) expect(mockSignAndSend).toHaveBeenNthCalledWith(1, txA, mockEVMApproveRequest.chain) expect(mockSignAndSend).toHaveBeenNthCalledWith(2, txB, mockEVMApproveRequest.chain) expect(result.hashes).toEqual(['0xh1', '0xh2']) }) it('propagates error after first tx succeeds (partial submit)', async () => { const txA = { ...( mockEVMApproveResponse.transactions as NonNullable< typeof mockEVMApproveResponse.transactions > )[0]! } const txB = { ...txA, data: '0xbbbb' } jest.spyOn(mpc, 'delegationsApprove').mockResolvedValue({ ...mockEVMApproveResponse, transactions: [txA, txB], }) const d = new Delegations({ mpc, signAndSendTransaction: mockSignAndSend, }) mockSignAndSend .mockResolvedValueOnce('0xh1') .mockRejectedValueOnce(new Error('second tx failed')) await expect(d.approveAndSubmit(mockEVMApproveRequest)).rejects.toThrow( 'second tx failed', ) expect(mockSignAndSend).toHaveBeenCalledTimes(2) }) it('per-call signAndSendTransaction overrides constructor signer', async () => { jest.spyOn(mpc, 'delegationsApprove').mockResolvedValue(mockEVMApproveResponse) const instanceSigner = jest.fn().mockResolvedValue('0xinstance') const perCallSigner = jest.fn().mockResolvedValue('0xpercall') const d = new Delegations({ mpc, signAndSendTransaction: instanceSigner, }) await d.approveAndSubmit(mockEVMApproveRequest, { signAndSendTransaction: perCallSigner, }) expect(instanceSigner).not.toHaveBeenCalled() expect(perCallSigner).toHaveBeenCalledTimes(1) }) it('throws on whitespace-only hash from signer', async () => { jest.spyOn(mpc, 'delegationsApprove').mockResolvedValue(mockEVMApproveResponse) mockSignAndSend.mockResolvedValue(' ') const d = new Delegations({ mpc, signAndSendTransaction: mockSignAndSend, }) await expect(d.approveAndSubmit(mockEVMApproveRequest)).rejects.toThrow( 'Invalid transaction hash', ) }) it('prefers transactions over encodedTransactions when both non-empty', async () => { const evmTx = mockEVMApproveResponse.transactions![0]! jest.spyOn(mpc, 'delegationsApprove').mockResolvedValue({ transactions: [evmTx], encodedTransactions: ['should-not-use'], metadata: mockEVMApproveResponse.metadata, }) const d = new Delegations({ mpc, signAndSendTransaction: mockSignAndSend, }) await d.approveAndSubmit(mockEVMApproveRequest) expect(mockSignAndSend).toHaveBeenCalledWith( evmTx, mockEVMApproveRequest.chain, ) }) it('setSignAndSendTransaction enables submit after construction', async () => { jest.spyOn(mpc, 'delegationsApprove').mockResolvedValue(mockEVMApproveResponse) const d = new Delegations({ mpc }) const late = jest.fn().mockResolvedValue('0xlate') d.setSignAndSendTransaction(late) const result = await d.approveAndSubmit(mockEVMApproveRequest) expect(late).toHaveBeenCalledTimes(1) expect(result).toEqual({ hashes: ['0xlate'] }) }) }) })