/** * @jest-environment jsdom */ import YieldXyz from './yieldxyz' import Mpc from '../../mpc' import portalMock from '../../__mocks/portal/portal' import { mockAddress, mockHost } from '../../__mocks/constants' import type { YieldXyzEnterYieldResponse, YieldXyzExitResponse } from '../../shared/types' describe('YieldXyz high-level (deposit, withdraw, defaults)', () => { let mpc: Mpc let yieldXyz: YieldXyz beforeEach(() => { jest.clearAllMocks() portalMock.host = mockHost mpc = new Mpc({ portal: portalMock }) yieldXyz = new YieldXyz({ mpc }) yieldXyz.setSignAndSendTransaction(jest.fn().mockResolvedValue('0xabc')) }) function enterResponseWithTxs( txs: Array<{ id: string network: string unsignedTransaction: unknown }>, ): YieldXyzEnterYieldResponse { return { data: { rawResponse: { id: 'action-1', intent: 'enter', type: 'STAKE', yieldId: 'resolved-yield', address: mockAddress, createdAt: '2024-01-01T00:00:00Z', status: 'CREATED', executionPattern: 'synchronous', transactions: txs, }, }, } as YieldXyzEnterYieldResponse } function exitResponseWithTxs( txs: Array<{ id: string network: string unsignedTransaction: unknown }>, ): YieldXyzExitResponse { return { data: { rawResponse: { id: 'action-1', intent: 'exit', type: 'UNSTAKE', yieldId: 'resolved-yield', address: mockAddress, createdAt: '2024-01-01T00:00:00Z', status: 'CREATED', executionPattern: 'synchronous', transactions: txs, }, }, } as YieldXyzExitResponse } it('deposit throws when no signer is configured', async () => { const y = new YieldXyz({ mpc }) await expect( y.deposit({ yieldId: 'y1', amount: '1', address: mockAddress }), ).rejects.toThrow( '[YieldXyz] No signer configured. Call setSignAndSendTransaction()', ) }) it('deposit throws when chain is not full CAIP-2', async () => { await expect( yieldXyz.deposit({ chain: '1', token: 'ETH', amount: '1', address: mockAddress, }), ).rejects.toThrow('full CAIP-2') }) it('deposit throws when neither yieldId nor chain+token', async () => { await expect( yieldXyz.deposit({ amount: '1', address: mockAddress } as never), ).rejects.toThrow('Provide either yieldId') }) it('resolveYieldIdFromPortalDefaults: throws when defaults endpoint returns error', async () => { jest.spyOn(mpc, 'getYieldXyzDefaults').mockResolvedValue({ error: 'boom', }) await expect( yieldXyz.deposit({ chain: 'eip155:1', token: 'ETH', amount: '1', address: mockAddress, }), ).rejects.toThrow('Failed to get yield defaults') }) it('resolveYieldIdFromPortalDefaults: throws when no data', async () => { jest.spyOn(mpc, 'getYieldXyzDefaults').mockResolvedValue({}) await expect( yieldXyz.deposit({ chain: 'eip155:1', token: 'ETH', amount: '1', address: mockAddress, }), ).rejects.toThrow('No data returned from yield defaults endpoint') }) it('resolveYieldIdFromPortalDefaults: throws when key missing', async () => { jest.spyOn(mpc, 'getYieldXyzDefaults').mockResolvedValue({ data: { 'eip155:1:WETH': { yieldId: 'y', opportunity: null } }, }) await expect( yieldXyz.deposit({ chain: 'eip155:1', token: 'ETH', amount: '1', address: mockAddress, }), ).rejects.toThrow('No default yield for key "eip155:1:ETH"') }) it('resolveYieldIdFromPortalDefaults: trims token for map key', async () => { jest.spyOn(mpc, 'getYieldXyzDefaults').mockResolvedValue({ data: { 'eip155:1:ETH': { yieldId: 'yield-from-defaults', opportunity: null }, }, }) const enterSpy = jest .spyOn(mpc, 'enterYieldXyzYield') .mockResolvedValue( enterResponseWithTxs([ { id: 'tx1', network: 'ethereum', unsignedTransaction: { to: '0x1', data: '0x' }, }, ]), ) jest.spyOn(mpc, 'trackYieldXyzTransaction').mockResolvedValue({ data: { rawResponse: { status: 'BROADCASTED' } }, } as never) await yieldXyz.deposit({ chain: 'eip155:1', token: ' ETH ', amount: '1', address: mockAddress, }) expect(mpc.getYieldXyzDefaults).toHaveBeenCalledWith({ includeOpportunities: false, }) expect(enterSpy).toHaveBeenCalledWith( expect.objectContaining({ yieldId: 'yield-from-defaults' }), ) }) it('deposit merges arguments so top-level amount wins', async () => { const enterSpy = jest .spyOn(mpc, 'enterYieldXyzYield') .mockResolvedValue( enterResponseWithTxs([ { id: 'tx1', network: 'eip155:1', unsignedTransaction: { to: '0x2', data: '0x' }, }, ]), ) jest.spyOn(mpc, 'trackYieldXyzTransaction').mockResolvedValue({}) await yieldXyz.deposit({ yieldId: 'y1', amount: '10', address: mockAddress, arguments: { amount: '99', validatorAddress: '0xv' }, }) expect(enterSpy).toHaveBeenCalledWith({ yieldId: 'y1', address: mockAddress, arguments: { amount: '10', validatorAddress: '0xv', }, }) }) it('withdraw merges arguments so top-level amount wins (consistent with deposit)', async () => { const exitSpy = jest.spyOn(mpc, 'exitYieldXyzYield').mockResolvedValue( exitResponseWithTxs([ { id: 'tx1', network: 'eip155:1', unsignedTransaction: { to: '0x2', data: '0x' }, }, ]), ) jest.spyOn(mpc, 'trackYieldXyzTransaction').mockResolvedValue({}) await yieldXyz.withdraw({ yieldId: 'y1', amount: '10', address: mockAddress, arguments: { amount: '77' }, }) expect(exitSpy).toHaveBeenCalledWith({ yieldId: 'y1', address: mockAddress, arguments: { amount: '10' }, }) }) it('executeAndTrack: throws when no transactions', async () => { jest.spyOn(mpc, 'enterYieldXyzYield').mockResolvedValue({ data: { rawResponse: { id: 'a', intent: 'enter', type: 'STAKE', yieldId: 'y', address: mockAddress, createdAt: '2024-01-01T00:00:00Z', status: 'CREATED', executionPattern: 'synchronous', transactions: [], }, }, } as YieldXyzEnterYieldResponse) await expect( yieldXyz.deposit({ yieldId: 'y', amount: '1', address: mockAddress }), ).rejects.toThrow('No transactions in yield action response.') }) it('executeAndTrack: single tx signs, tracks, returns hashes', async () => { const sign = jest.fn().mockResolvedValue('0xsig') yieldXyz.setSignAndSendTransaction(sign) jest.spyOn(mpc, 'enterYieldXyzYield').mockResolvedValue( enterResponseWithTxs([ { id: 'tx1', network: 'ethereum', unsignedTransaction: JSON.stringify({ to: '0xabc', nonce: '0x1', data: '0x', }), }, ]), ) const trackSpy = jest .spyOn(mpc, 'trackYieldXyzTransaction') .mockResolvedValue({}) const result = await yieldXyz.deposit({ yieldId: 'rid', amount: '1', address: mockAddress }) expect(sign).toHaveBeenCalledTimes(1) expect(trackSpy).toHaveBeenCalledWith({ transactionId: 'tx1', hash: '0xsig', }) expect(result.hashes).toEqual(['0xsig']) expect(result.yieldId).toBe('resolved-yield') }) it('executeAndTrack: multi-tx sequential', async () => { const sign = jest .fn() .mockResolvedValueOnce('0x1') .mockResolvedValueOnce('0x2') yieldXyz.setSignAndSendTransaction(sign) jest.spyOn(mpc, 'enterYieldXyzYield').mockResolvedValue( enterResponseWithTxs([ { id: 'a', network: 'eip155:1', unsignedTransaction: { to: '0x1' }, }, { id: 'b', network: 'eip155:1', unsignedTransaction: { to: '0x2' }, }, ]), ) jest.spyOn(mpc, 'trackYieldXyzTransaction').mockResolvedValue({}) const result = await yieldXyz.deposit({ yieldId: 'r', amount: '1', address: mockAddress }) expect(sign).toHaveBeenCalledTimes(2) expect(result.hashes).toEqual(['0x1', '0x2']) }) it('waitForConfirmation false: still tracks but no confirmed progress', async () => { const progress: string[] = [] const waiter = jest.fn().mockResolvedValue(false) const y = new YieldXyz({ mpc, waitForConfirmation: waiter, }) y.setSignAndSendTransaction(jest.fn().mockResolvedValue('0xh')) jest.spyOn(mpc, 'enterYieldXyzYield').mockResolvedValue( enterResponseWithTxs([ { id: 'tx1', network: 'eip155:1', unsignedTransaction: { to: '0x1' }, }, ]), ) const trackSpy = jest .spyOn(mpc, 'trackYieldXyzTransaction') .mockResolvedValue({}) await y.deposit({ yieldId: 'y', amount: '1', address: mockAddress }, { onProgress: (e) => progress.push(e.step), }) expect(progress).toEqual([ 'signing', 'submitted', 'confirming', ]) expect(progress.includes('confirmed')).toBe(false) expect(trackSpy).toHaveBeenCalledWith({ transactionId: 'tx1', hash: '0xh', }) }) it('waitForConfirmation true: emits confirmed', async () => { const progress: string[] = [] const y = new YieldXyz({ mpc, waitForConfirmation: jest.fn().mockResolvedValue(true), }) y.setSignAndSendTransaction(jest.fn().mockResolvedValue('0xh')) jest.spyOn(mpc, 'enterYieldXyzYield').mockResolvedValue( enterResponseWithTxs([ { id: 'tx1', network: 'eip155:1', unsignedTransaction: { to: '0x1' }, }, ]), ) jest.spyOn(mpc, 'trackYieldXyzTransaction').mockResolvedValue({}) await y.deposit({ yieldId: 'y', amount: '1', address: mockAddress }, { onProgress: (e) => progress.push(e.step), }) expect(progress).toContain('confirmed') }) it('echoes chain and token on result when resolved from defaults', async () => { jest.spyOn(mpc, 'getYieldXyzDefaults').mockResolvedValue({ data: { 'eip155:1:ETH': { yieldId: 'ydef', opportunity: null }, }, }) jest.spyOn(mpc, 'enterYieldXyzYield').mockResolvedValue( enterResponseWithTxs([ { id: 'tx1', network: 'eip155:1', unsignedTransaction: { to: '0x1' }, }, ]), ) jest.spyOn(mpc, 'trackYieldXyzTransaction').mockResolvedValue({}) const r = await yieldXyz.deposit({ chain: 'eip155:1', token: 'ETH', amount: '1', address: mockAddress, }) expect(r.chain).toBe('eip155:1') expect(r.token).toBe('ETH') }) it('status=SUCCESS when no waitForConfirmation configured', async () => { jest.spyOn(mpc, 'enterYieldXyzYield').mockResolvedValue( enterResponseWithTxs([ { id: 'tx1', network: 'eip155:1', unsignedTransaction: { to: '0x1' }, }, ]), ) jest.spyOn(mpc, 'trackYieldXyzTransaction').mockResolvedValue({}) const r = await yieldXyz.deposit({ yieldId: 'y', amount: '1', address: mockAddress, }) expect(r.status).toBe('SUCCESS') }) it('status=SUCCESS when all confirmations reach true', async () => { const y = new YieldXyz({ mpc, waitForConfirmation: jest.fn().mockResolvedValue(true), }) y.setSignAndSendTransaction(jest.fn().mockResolvedValue('0xh')) jest.spyOn(mpc, 'enterYieldXyzYield').mockResolvedValue( enterResponseWithTxs([ { id: 'tx1', network: 'eip155:1', unsignedTransaction: { to: '0x1' }, }, { id: 'tx2', network: 'eip155:1', unsignedTransaction: { to: '0x2' }, }, ]), ) jest.spyOn(mpc, 'trackYieldXyzTransaction').mockResolvedValue({}) const r = await y.deposit({ yieldId: 'y', amount: '1', address: mockAddress }) expect(r.status).toBe('SUCCESS') expect(r.hashes).toHaveLength(2) }) it('status=FAILED when first confirmation returns false', async () => { const y = new YieldXyz({ mpc, waitForConfirmation: jest.fn().mockResolvedValue(false), }) y.setSignAndSendTransaction(jest.fn().mockResolvedValue('0xh')) jest.spyOn(mpc, 'enterYieldXyzYield').mockResolvedValue( enterResponseWithTxs([ { id: 'tx1', network: 'eip155:1', unsignedTransaction: { to: '0x1' }, }, ]), ) jest.spyOn(mpc, 'trackYieldXyzTransaction').mockResolvedValue({}) const r = await y.deposit({ yieldId: 'y', amount: '1', address: mockAddress }) expect(r.status).toBe('FAILED') expect(r.hashes).toEqual(['0xh']) }) it('status=PARTIAL_SUCCESS when first tx confirms but second fails', async () => { const waiter = jest .fn() .mockResolvedValueOnce(true) .mockResolvedValueOnce(false) const y = new YieldXyz({ mpc, waitForConfirmation: waiter, }) const signer = jest .fn() .mockResolvedValueOnce('0xh1') .mockResolvedValueOnce('0xh2') y.setSignAndSendTransaction(signer) jest.spyOn(mpc, 'enterYieldXyzYield').mockResolvedValue( enterResponseWithTxs([ { id: 'tx1', network: 'eip155:1', unsignedTransaction: { to: '0x1' }, }, { id: 'tx2', network: 'eip155:1', unsignedTransaction: { to: '0x2' }, }, ]), ) jest.spyOn(mpc, 'trackYieldXyzTransaction').mockResolvedValue({}) const r = await y.deposit({ yieldId: 'y', amount: '1', address: mockAddress }) expect(r.status).toBe('PARTIAL_SUCCESS') expect(r.hashes).toEqual(['0xh1', '0xh2']) expect(signer).toHaveBeenCalledTimes(2) expect(waiter).toHaveBeenCalledTimes(2) }) it('withdraw: status=FAILED when confirmation returns false', async () => { const y = new YieldXyz({ mpc, waitForConfirmation: jest.fn().mockResolvedValue(false), }) y.setSignAndSendTransaction(jest.fn().mockResolvedValue('0xh')) jest.spyOn(mpc, 'exitYieldXyzYield').mockResolvedValue( exitResponseWithTxs([ { id: 'tx1', network: 'eip155:1', unsignedTransaction: { to: '0x1' }, }, ]), ) jest.spyOn(mpc, 'trackYieldXyzTransaction').mockResolvedValue({}) const r = await y.withdraw({ yieldId: 'y', amount: '1', address: mockAddress }) expect(r.status).toBe('FAILED') expect(r.hashes).toEqual(['0xh']) }) })