diff --git a/packages/ui-components/src/__tests__/DeploymentSteps.test.ts b/packages/ui-components/src/__tests__/DeploymentSteps.test.ts index 46566e7f3..6836d4e63 100644 --- a/packages/ui-components/src/__tests__/DeploymentSteps.test.ts +++ b/packages/ui-components/src/__tests__/DeploymentSteps.test.ts @@ -2,11 +2,13 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { render, screen, waitFor } from '@testing-library/svelte'; import DeploymentSteps from '../lib/components/deployment/DeploymentSteps.svelte'; import { DotrainOrderGui, type Scenario } from '@rainlanguage/orderbook/js_api'; - import type { ComponentProps } from 'svelte'; import { writable } from 'svelte/store'; import type { AppKit } from '@reown/appkit'; import type { ConfigSource } from '../lib/typeshare/config'; +import type { DeploymentArgs } from '$lib/types/transaction'; +import type { DisclaimerModal } from '$lib'; + const { mockWagmiConfigStore, mockConnectedStore } = await vi.hoisted( () => import('../lib/__mocks__/stores') ); @@ -634,7 +636,10 @@ describe('DeploymentSteps', () => { wagmiConfig: mockWagmiConfigStore, wagmiConnected: mockConnectedStore, appKitModal: writable({} as AppKit), - handleDeployModal: vi.fn(), + handleDeployModal: vi.fn() as unknown as (args: DeploymentArgs) => void, + handleDisclaimerModal: vi.fn() as unknown as ( + args: ComponentProps + ) => void, settings: writable({} as ConfigSource), handleUpdateGuiState: vi.fn() } @@ -660,7 +665,10 @@ describe('DeploymentSteps', () => { wagmiConfig: mockWagmiConfigStore, wagmiConnected: mockConnectedStore, appKitModal: writable({} as AppKit), - handleDeployModal: vi.fn(), + handleDeployModal: vi.fn() as unknown as (args: DeploymentArgs) => void, + handleDisclaimerModal: vi.fn() as unknown as ( + args: ComponentProps + ) => void, settings: writable({} as ConfigSource), handleUpdateGuiState: vi.fn() } @@ -686,7 +694,10 @@ describe('DeploymentSteps', () => { wagmiConfig: mockWagmiConfigStore, wagmiConnected: mockConnectedStore, appKitModal: writable({} as AppKit), - handleDeployModal: vi.fn(), + handleDeployModal: vi.fn() as unknown as (args: DeploymentArgs) => void, + handleDisclaimerModal: vi.fn() as unknown as ( + args: ComponentProps + ) => void, settings: writable({} as ConfigSource), handleUpdateGuiState: vi.fn() } @@ -723,7 +734,10 @@ describe('DeploymentSteps', () => { wagmiConfig: mockWagmiConfigStore, wagmiConnected: mockConnectedStore, appKitModal: writable({} as AppKit), - handleDeployModal: vi.fn(), + handleDeployModal: vi.fn() as unknown as (args: DeploymentArgs) => void, + handleDisclaimerModal: vi.fn() as unknown as ( + args: ComponentProps + ) => void, settings: writable({} as ConfigSource), handleUpdateGuiState: vi.fn() } @@ -758,7 +772,10 @@ describe('DeploymentSteps', () => { wagmiConfig: mockWagmiConfigStore, wagmiConnected: mockConnectedStore, appKitModal: writable({} as AppKit), - handleDeployModal: vi.fn(), + handleDeployModal: vi.fn() as unknown as (args: DeploymentArgs) => void, + handleDisclaimerModal: vi.fn() as unknown as ( + args: ComponentProps + ) => void, settings: writable({} as ConfigSource), handleUpdateGuiState: vi.fn() } diff --git a/packages/ui-components/src/__tests__/DisclaimerModal.test.ts b/packages/ui-components/src/__tests__/DisclaimerModal.test.ts new file mode 100644 index 000000000..4c6387321 --- /dev/null +++ b/packages/ui-components/src/__tests__/DisclaimerModal.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import DisclaimerModal from '../lib/components/deployment/DisclaimerModal.svelte'; + +describe('DisclaimerModal', () => { + const mockOnAccept = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls onAccept when accepting disclaimer', async () => { + render(DisclaimerModal, { + props: { + open: true, + onAccept: mockOnAccept + } + }); + + const deployButton = await screen.findByText('Deploy'); + await fireEvent.click(deployButton); + + expect(mockOnAccept).toHaveBeenCalled(); + }); +}); 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..286505ecb --- /dev/null +++ b/packages/ui-components/src/__tests__/getDeploymentTransactionArgs.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach, type Mock } 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 Mock).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 Mock).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 124d917a7..de13b4f6c 100644 --- a/packages/ui-components/src/lib/components/deployment/DeploymentSteps.svelte +++ b/packages/ui-components/src/lib/components/deployment/DeploymentSteps.svelte @@ -1,11 +1,11 @@ + + +
+
+ +
+ + + 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.
  • +
+
+
+ + +
+
+
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 + }; +} diff --git a/packages/ui-components/src/lib/components/wallet/WalletConnect.svelte b/packages/ui-components/src/lib/components/wallet/WalletConnect.svelte index b222936e3..8e2f5896e 100644 --- a/packages/ui-components/src/lib/components/wallet/WalletConnect.svelte +++ b/packages/ui-components/src/lib/components/wallet/WalletConnect.svelte @@ -3,8 +3,10 @@ import { CheckCircleOutline } from 'flowbite-svelte-icons'; import type { Writable } from 'svelte/store'; import type { AppKit } from '@reown/appkit'; + import { twMerge } from 'tailwind-merge'; export let appKitModal: Writable; export let connected: Writable; + export let classes: string = ''; function handleClick() { $appKitModal.open(); } @@ -13,7 +15,8 @@