diff --git a/packages/ui-components/src/__tests__/getDeploymentTransactionArgs.test.ts b/packages/ui-components/src/__tests__/getDeploymentTransactionArgs.test.ts new file mode 100644 index 000000000..67c858ebd --- /dev/null +++ b/packages/ui-components/src/__tests__/getDeploymentTransactionArgs.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + getDeploymentTransactionArgs, + AddOrderErrors +} from '../lib/components/deployment/getDeploymentTransactionArgs'; +import { getAccount } from '@wagmi/core'; +import type { Config } from '@wagmi/core'; +import type { DotrainOrderGui, OrderIO } from '@rainlanguage/orderbook/js_api'; + +// Mock wagmi/core +vi.mock('@wagmi/core', () => ({ + getAccount: vi.fn() +})); + +describe('getDeploymentTransactionArgs', () => { + let mockGui: DotrainOrderGui; + let mockWagmiConfig: Config; + let mockTokenOutputs: OrderIO[]; + + beforeEach(() => { + vi.clearAllMocks(); + + // Mock GUI with successful responses + mockGui = { + generateApprovalCalldatas: vi.fn().mockResolvedValue([{ token: '0x123', amount: '1000' }]), + generateDepositAndAddOrderCalldatas: vi.fn().mockResolvedValue({ + deposit: '0xdeposit', + addOrder: '0xaddOrder' + }), + getCurrentDeployment: vi.fn().mockReturnValue({ + deployment: { + order: { + network: { 'chain-id': 1 }, + orderbook: { address: '0xorderbook' } + } + } + }), + getTokenInfo: vi.fn().mockResolvedValue({ + address: '0x123', + symbol: 'TEST' + }) + } as unknown as DotrainOrderGui; + + mockWagmiConfig = {} as Config; + (getAccount as any).mockReturnValue({ address: '0xuser' }); + + mockTokenOutputs = [{ token: { key: 'token1' } }] as OrderIO[]; + }); + + describe('successful cases', () => { + it('should successfully return deployment transaction args', async () => { + const result = await getDeploymentTransactionArgs(mockGui, mockWagmiConfig, mockTokenOutputs); + + expect(result).toEqual({ + approvals: [{ token: '0x123', amount: '1000', symbol: 'TEST' }], + deploymentCalldata: { + deposit: '0xdeposit', + addOrder: '0xaddOrder' + }, + orderbookAddress: '0xorderbook', + chainId: 1 + }); + + expect(mockGui.generateApprovalCalldatas).toHaveBeenCalledWith('0xuser'); + expect(mockGui.generateDepositAndAddOrderCalldatas).toHaveBeenCalled(); + }); + }); + + describe('input validation errors', () => { + it('should throw MISSING_GUI when GUI is null', async () => { + await expect( + getDeploymentTransactionArgs(null, mockWagmiConfig, mockTokenOutputs) + ).rejects.toThrow(AddOrderErrors.MISSING_GUI); + }); + + it('should throw MISSING_CONFIG when wagmiConfig is undefined', async () => { + await expect( + getDeploymentTransactionArgs(mockGui, undefined, mockTokenOutputs) + ).rejects.toThrow(AddOrderErrors.MISSING_CONFIG); + }); + + it('should throw NO_WALLET when wallet address is not found', async () => { + (getAccount as any).mockReturnValue({ address: null }); + await expect( + getDeploymentTransactionArgs(mockGui, mockWagmiConfig, mockTokenOutputs) + ).rejects.toThrow(AddOrderErrors.NO_WALLET); + }); + }); + + describe('deployment errors', () => { + it('should throw INVALID_CHAIN_ID when chain ID is missing', async () => { + mockGui.getCurrentDeployment = vi.fn().mockReturnValue({ + deployment: { + order: { + network: {}, + orderbook: { address: '0xorderbook' } + } + } + }); + + await expect( + getDeploymentTransactionArgs(mockGui, mockWagmiConfig, mockTokenOutputs) + ).rejects.toThrow(AddOrderErrors.INVALID_CHAIN_ID); + }); + + it('should throw MISSING_ORDERBOOK when orderbook address is missing', async () => { + mockGui.getCurrentDeployment = vi.fn().mockReturnValue({ + deployment: { + order: { + network: { 'chain-id': 1 }, + orderbook: {} + } + } + }); + + await expect( + getDeploymentTransactionArgs(mockGui, mockWagmiConfig, mockTokenOutputs) + ).rejects.toThrow(AddOrderErrors.MISSING_ORDERBOOK); + }); + }); + + describe('approval and calldata errors', () => { + it('should throw APPROVAL_FAILED when generateApprovalCalldatas fails', async () => { + mockGui.generateApprovalCalldatas = vi.fn().mockRejectedValue(new Error('Approval error')); + + await expect( + getDeploymentTransactionArgs(mockGui, mockWagmiConfig, mockTokenOutputs) + ).rejects.toThrow(`${AddOrderErrors.APPROVAL_FAILED}: Approval error`); + }); + + it('should throw DEPLOYMENT_FAILED when generateDepositAndAddOrderCalldatas fails', async () => { + mockGui.generateDepositAndAddOrderCalldatas = vi + .fn() + .mockRejectedValue(new Error('Deployment error')); + + await expect( + getDeploymentTransactionArgs(mockGui, mockWagmiConfig, mockTokenOutputs) + ).rejects.toThrow(`${AddOrderErrors.DEPLOYMENT_FAILED}: Deployment error`); + }); + }); + + describe('token info errors', () => { + it('should throw TOKEN_INFO_FAILED when token key is missing', async () => { + const invalidTokenOutputs = [{ token: {} }] as OrderIO[]; + + await expect( + getDeploymentTransactionArgs(mockGui, mockWagmiConfig, invalidTokenOutputs) + ).rejects.toThrow(`${AddOrderErrors.TOKEN_INFO_FAILED}: Token key is missing`); + }); + + it('should throw TOKEN_INFO_FAILED when getTokenInfo fails', async () => { + mockGui.getTokenInfo = vi.fn().mockRejectedValue(new Error('Token info error')); + + await expect( + getDeploymentTransactionArgs(mockGui, mockWagmiConfig, mockTokenOutputs) + ).rejects.toThrow(`${AddOrderErrors.TOKEN_INFO_FAILED}: Token info error`); + }); + + it('should throw TOKEN_INFO_FAILED when token info is not found for approval', async () => { + mockGui.getTokenInfo = vi.fn().mockResolvedValue({ + address: '0x456', // Different address than the approval token + symbol: 'TEST' + }); + + await expect( + getDeploymentTransactionArgs(mockGui, mockWagmiConfig, mockTokenOutputs) + ).rejects.toThrow( + `${AddOrderErrors.TOKEN_INFO_FAILED}: Token info not found for address: 0x123` + ); + }); + }); +}); diff --git a/packages/ui-components/src/lib/components/deployment/DeploymentSteps.svelte b/packages/ui-components/src/lib/components/deployment/DeploymentSteps.svelte index c8e2454ba..676e2c148 100644 --- a/packages/ui-components/src/lib/components/deployment/DeploymentSteps.svelte +++ b/packages/ui-components/src/lib/components/deployment/DeploymentSteps.svelte @@ -18,13 +18,16 @@ } from '@rainlanguage/orderbook/js_api'; import { fade } from 'svelte/transition'; import { Button, Toggle } from 'flowbite-svelte'; - import { getAccount, type Config } from '@wagmi/core'; + import { type Config } from '@wagmi/core'; import { type Writable } from 'svelte/store'; import type { AppKit } from '@reown/appkit'; import type { Hex } from 'viem'; import { page } from '$app/stores'; import { onMount } from 'svelte'; import ShareChoicesButton from './ShareChoicesButton.svelte'; + import { getDeploymentTransactionArgs } from './getDeploymentTransactionArgs'; + import DisclaimerModal from './DisclaimerModal.svelte'; + enum DeploymentStepErrors { NO_GUI = 'Error loading GUI', NO_STRATEGY = 'No valid strategy exists at this URL', @@ -59,6 +62,7 @@ let gui: DotrainOrderGui | null = null; let error: DeploymentStepErrors | null = null; let errorDetails: string | null = null; + let showDisclaimerModal = false; export let wagmiConfig: Writable; export let wagmiConnected: Writable; @@ -155,34 +159,15 @@ } } - async function handleAddOrder() { - try { - if (!gui || !$wagmiConfig) return; - const { address } = getAccount($wagmiConfig); - if (!address) return; - let approvals = await gui.generateApprovalCalldatas(address); - const deploymentCalldata = await gui.generateDepositAndAddOrderCalldatas(); - const chainId = gui.getCurrentDeployment().deployment.order.network['chain-id'] as number; - // @ts-expect-error orderbook is not typed - const orderbookAddress = gui.getCurrentDeployment().deployment.order.orderbook.address; - const outputTokenInfos = await Promise.all( - allTokenOutputs.map((token) => gui?.getTokenInfo(token.token?.key as string)) - ); - - approvals = approvals.map((approval) => { - const token = outputTokenInfos.find((token) => token?.address === approval.token); - return { - ...approval, - symbol: token?.symbol - }; - }); + async function handleAddOrderClick() { + showDisclaimerModal = true; + } - handleDeployModal({ - approvals, - deploymentCalldata, - orderbookAddress, - chainId - }); + async function handleAcceptDisclaimer() { + try { + showDisclaimerModal = false; + const result = await getDeploymentTransactionArgs(gui, $wagmiConfig, allTokenOutputs); + handleDeployModal(result); } catch (e) { error = DeploymentStepErrors.ADD_ORDER_FAILED; errorDetails = e instanceof Error ? e.message : 'Unknown error'; @@ -278,7 +263,7 @@
{#if $wagmiConnected} - + {:else} @@ -299,3 +284,13 @@ {/if} {/if}
+ +{#if showDisclaimerModal && gui && allTokenOutputs && wagmiConfig} + +{/if} diff --git a/packages/ui-components/src/lib/components/deployment/DisclaimerModal.svelte b/packages/ui-components/src/lib/components/deployment/DisclaimerModal.svelte new file mode 100644 index 000000000..fa2d9e310 --- /dev/null +++ b/packages/ui-components/src/lib/components/deployment/DisclaimerModal.svelte @@ -0,0 +1,107 @@ + + + +
+
+ +
+ + + Before you deploy your strategy, make sure you understand the following... + +
+
+
    +
  • + This front end is provided as a tool to interact with the Raindex smart contracts. +
  • +
  • + You are deploying your own strategy and depositing funds to an immutable smart contract + using your own wallet and private keys. +
  • +
  • + Nobody is custodying your funds, there is no recourse for recovery of funds if lost. +
  • +
  • There is no endorsement or guarantee provided with these strategies.
  • +
  • + Do not proceed if you do not understand the strategy you are deploying. +
  • +
  • Do not invest unless you are prepared to lose all funds.
  • +
+
+
+ + +
+
+ {#if error} + {error} + {/if} + {#if errorDetails} + {errorDetails} + {/if} +
+
+
diff --git a/packages/ui-components/src/lib/components/deployment/DisclaimerModal.test.ts b/packages/ui-components/src/lib/components/deployment/DisclaimerModal.test.ts new file mode 100644 index 000000000..2d40f7090 --- /dev/null +++ b/packages/ui-components/src/lib/components/deployment/DisclaimerModal.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import DisclaimerModal from './DisclaimerModal.svelte'; +import { getDeploymentTransactionArgs } from './getDeploymentTransactionArgs'; +import { writable } from 'svelte/store'; + +// Mock the getDeploymentTransactionArgs module +vi.mock('./getDeploymentTransactionArgs', () => ({ + getDeploymentTransactionArgs: vi.fn() +})); + +describe('DisclaimerModal', () => { + const mockGui = {} as any; + const mockOutputs = [] as any[]; + const mockWagmiConfig = writable(undefined); + const mockHandleDeployModal = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls getDeploymentTransactionArgs when opened', async () => { + const mockResult = { + approvals: {}, + deploymentCalldata: {}, + orderbookAddress: '0x123', + chainId: 1 + }; + (getDeploymentTransactionArgs as any).mockResolvedValue(mockResult); + + render(DisclaimerModal, { + props: { + open: true, + gui: mockGui, + allTokenOutputs: mockOutputs, + wagmiConfig: mockWagmiConfig, + handleDeployModal: mockHandleDeployModal + } + }); + + expect(getDeploymentTransactionArgs).toHaveBeenCalledWith(mockGui, undefined, mockOutputs); + }); + + it('shows error message when getDeploymentTransactionArgs fails', async () => { + const errorMessage = 'Test error message'; + (getDeploymentTransactionArgs as any).mockRejectedValue(new Error(errorMessage)); + + render(DisclaimerModal, { + props: { + open: true, + gui: mockGui, + allTokenOutputs: mockOutputs, + wagmiConfig: mockWagmiConfig, + handleDeployModal: mockHandleDeployModal + } + }); + + const errorText = await screen.findByText('Error getting deployment transaction data:'); + const errorDetails = await screen.findByText(errorMessage); + expect(errorText).toBeInTheDocument(); + expect(errorDetails).toBeInTheDocument(); + }); + + it('calls handleDeployModal with result when accepting disclaimer', async () => { + const mockResult = { + approvals: {}, + deploymentCalldata: {}, + orderbookAddress: '0x123', + chainId: 1 + }; + (getDeploymentTransactionArgs as any).mockResolvedValue(mockResult); + + render(DisclaimerModal, { + props: { + open: true, + gui: mockGui, + allTokenOutputs: mockOutputs, + wagmiConfig: mockWagmiConfig, + handleDeployModal: mockHandleDeployModal + } + }); + + // Wait for the Deploy button to be enabled + const deployButton = await screen.findByText('Deploy'); + await fireEvent.click(deployButton); + + expect(mockHandleDeployModal).toHaveBeenCalledWith(mockResult); + }); +}); diff --git a/packages/ui-components/src/lib/components/deployment/getDeploymentTransactionArgs.ts b/packages/ui-components/src/lib/components/deployment/getDeploymentTransactionArgs.ts new file mode 100644 index 000000000..654af40d4 --- /dev/null +++ b/packages/ui-components/src/lib/components/deployment/getDeploymentTransactionArgs.ts @@ -0,0 +1,109 @@ +import type { Config } from '@wagmi/core'; +import { getAccount } from '@wagmi/core'; +import type { + ApprovalCalldataResult, + DepositAndAddOrderCalldataResult, + DotrainOrderGui +} from '@rainlanguage/orderbook/js_api'; +import type { OrderIO } from '@rainlanguage/orderbook/js_api'; +import type { Hex } from 'viem'; + +export enum AddOrderErrors { + ADD_ORDER_FAILED = 'Failed to add order', + MISSING_GUI = 'Order GUI is required', + MISSING_CONFIG = 'Wagmi config is required', + NO_WALLET = 'No wallet address found', + INVALID_CHAIN_ID = 'Invalid chain ID in deployment', + MISSING_ORDERBOOK = 'Orderbook address not found', + TOKEN_INFO_FAILED = 'Failed to fetch token information', + APPROVAL_FAILED = 'Failed to generate approval calldata', + DEPLOYMENT_FAILED = 'Failed to generate deployment calldata' +} + +export interface HandleAddOrderResult { + approvals: ApprovalCalldataResult; + deploymentCalldata: DepositAndAddOrderCalldataResult; + orderbookAddress: Hex; + chainId: number; +} + +export async function getDeploymentTransactionArgs( + gui: DotrainOrderGui | null, + wagmiConfig: Config | undefined, + allTokenOutputs: OrderIO[] +): Promise { + if (!gui) { + throw new Error(AddOrderErrors.MISSING_GUI); + } + + if (!wagmiConfig) { + throw new Error(AddOrderErrors.MISSING_CONFIG); + } + + const { address } = getAccount(wagmiConfig); + if (!address) { + throw new Error(AddOrderErrors.NO_WALLET); + } + + let approvals; + try { + approvals = await gui.generateApprovalCalldatas(address); + } catch (error) { + throw new Error( + `${AddOrderErrors.APPROVAL_FAILED}: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + + let deploymentCalldata; + try { + deploymentCalldata = await gui.generateDepositAndAddOrderCalldatas(); + } catch (error) { + throw new Error( + `${AddOrderErrors.DEPLOYMENT_FAILED}: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + + const currentDeployment = gui.getCurrentDeployment(); + const chainId = currentDeployment?.deployment?.order?.network?.['chain-id'] as number; + if (!chainId) { + throw new Error(AddOrderErrors.INVALID_CHAIN_ID); + } + + // @ts-expect-error orderbook is not typed + const orderbookAddress = currentDeployment?.deployment?.order?.orderbook?.address; + if (!orderbookAddress) { + throw new Error(AddOrderErrors.MISSING_ORDERBOOK); + } + + try { + const outputTokenInfos = await Promise.all( + allTokenOutputs.map((token) => { + const key = token.token?.key; + if (!key) throw new Error('Token key is missing'); + return gui.getTokenInfo(key); + }) + ); + + approvals = approvals.map((approval) => { + const token = outputTokenInfos.find((token) => token?.address === approval.token); + if (!token) { + throw new Error(`Token info not found for address: ${approval.token}`); + } + return { + ...approval, + symbol: token.symbol + }; + }); + } catch (error) { + throw new Error( + `${AddOrderErrors.TOKEN_INFO_FAILED}: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + + return { + approvals, + deploymentCalldata, + orderbookAddress, + chainId + }; +}