diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..ee7d88e --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,19 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + }, +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cffe47..c2cfc17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,3 +6,7 @@ - Transaction history - Dark mode - Exchange rate display + +### Fixed +- EscrowForm now rejects self-escrow (beneficiary === depositor) and + arbiter addresses that match the depositor or beneficiary (#23) diff --git a/src/components/Escrow/EscrowForm.test.tsx b/src/components/Escrow/EscrowForm.test.tsx new file mode 100644 index 0000000..f7c692c --- /dev/null +++ b/src/components/Escrow/EscrowForm.test.tsx @@ -0,0 +1,105 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi } from 'vitest' +import { EscrowForm } from './EscrowForm' + +const DEPOSITOR = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' +const BENEFICIARY = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' +const ARBITER = 'GCV37CL42Z5KTWLABYCGUIP3X5HRMAAOWCKT75LSQEQUBV3MSJ5FRFRR' + +const supportedAssets = [{ code: 'XLM', name: 'Stellar Lumens' }] + +async function fillCommonFields() { + const amountInput = screen.getByLabelText(/amount/i) + fireEvent.change(amountInput, { target: { value: '10' } }) + fireEvent.blur(amountInput) + + const unlockInput = screen.getByLabelText(/unlock time/i) + const future = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 16) + fireEvent.change(unlockInput, { target: { value: future } }) + fireEvent.blur(unlockInput) +} + +describe('EscrowForm self-escrow / arbiter guards', () => { + it('disables submit and shows an error when beneficiary equals the connected wallet', async () => { + const user = userEvent.setup() + render( + , + ) + + await user.type(screen.getByLabelText(/beneficiary stellar address/i), DEPOSITOR) + await fillCommonFields() + + await waitFor(() => { + expect(screen.getByText(/self-escrow is not allowed/i)).toBeInTheDocument() + }) + expect(screen.getByRole('button', { name: /review escrow/i })).toBeDisabled() + }) + + it('disables submit and shows an error when arbiter equals the connected wallet', async () => { + const user = userEvent.setup() + render( + , + ) + + await user.type(screen.getByLabelText(/beneficiary stellar address/i), BENEFICIARY) + await user.type(screen.getByLabelText(/arbiter address/i), DEPOSITOR) + await fillCommonFields() + + await waitFor(() => { + expect(screen.getByText(/arbiter cannot be your own wallet address/i)).toBeInTheDocument() + }) + expect(screen.getByRole('button', { name: /review escrow/i })).toBeDisabled() + }) + + it('disables submit and shows an error when arbiter equals the beneficiary', async () => { + const user = userEvent.setup() + render( + , + ) + + await user.type(screen.getByLabelText(/beneficiary stellar address/i), BENEFICIARY) + await user.type(screen.getByLabelText(/arbiter address/i), BENEFICIARY) + await fillCommonFields() + + await waitFor(() => { + expect(screen.getByText(/same address as the beneficiary/i)).toBeInTheDocument() + }) + expect(screen.getByRole('button', { name: /review escrow/i })).toBeDisabled() + }) + + it('allows submission with distinct depositor, beneficiary, and arbiter addresses', async () => { + const user = userEvent.setup() + const onSubmit = vi.fn() + render( + , + ) + + await user.type(screen.getByLabelText(/beneficiary stellar address/i), BENEFICIARY) + await user.type(screen.getByLabelText(/arbiter address/i), ARBITER) + await fillCommonFields() + + await waitFor(() => { + expect(screen.getByRole('button', { name: /review escrow/i })).toBeEnabled() + }) + + await user.click(screen.getByRole('button', { name: /review escrow/i })) + expect(onSubmit).toHaveBeenCalled() + }) +}) diff --git a/src/components/Escrow/EscrowForm.tsx b/src/components/Escrow/EscrowForm.tsx index 0c21f8f..4b96c21 100644 --- a/src/components/Escrow/EscrowForm.tsx +++ b/src/components/Escrow/EscrowForm.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useMemo } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' @@ -15,36 +15,74 @@ const minUnlockLocal = () => { return d.toISOString().slice(0, 16) } -const escrowSchema = z - .object({ - beneficiaryPublicKey: z - .string() - .min(1, 'Beneficiary address is required') - .refine(isValidStellarAddress, 'Invalid Stellar address'), - arbiterPublicKey: z - .string() - .optional() - .default('') - .refine((v) => v === '' || isValidStellarAddress(v), 'Invalid Stellar address'), - assetCode: z.string().min(1), - amount: z - .string() - .min(1, 'Amount is required') - .refine((v) => !isNaN(parseFloat(v)) && parseFloat(v) > 0, 'Amount must be a positive number'), - unlockDate: z.string().min(1, 'Unlock time is required'), - }) - .refine((v) => new Date(v.unlockDate).getTime() > Date.now(), { - message: 'Unlock time must be in the future', - path: ['unlockDate'], - }) +function buildEscrowSchema(depositorPublicKey: string | null) { + return z + .object({ + beneficiaryPublicKey: z + .string() + .min(1, 'Beneficiary address is required') + .refine(isValidStellarAddress, 'Invalid Stellar address'), + arbiterPublicKey: z + .string() + .optional() + .default('') + .refine((v) => v === '' || isValidStellarAddress(v), 'Invalid Stellar address'), + assetCode: z.string().min(1), + amount: z + .string() + .min(1, 'Amount is required') + .refine((v) => !isNaN(parseFloat(v)) && parseFloat(v) > 0, 'Amount must be a positive number'), + unlockDate: z.string().min(1, 'Unlock time is required'), + }) + .refine((v) => new Date(v.unlockDate).getTime() > Date.now(), { + message: 'Unlock time must be in the future', + path: ['unlockDate'], + }) + .refine( + (v) => + !depositorPublicKey || + v.beneficiaryPublicKey === '' || + v.beneficiaryPublicKey !== depositorPublicKey, + { + message: 'Beneficiary cannot be your own wallet address (self-escrow is not allowed)', + path: ['beneficiaryPublicKey'], + }, + ) + .refine( + (v) => + !depositorPublicKey || v.arbiterPublicKey === '' || v.arbiterPublicKey !== depositorPublicKey, + { + message: 'Arbiter cannot be your own wallet address', + path: ['arbiterPublicKey'], + }, + ) + .refine( + (v) => + v.arbiterPublicKey === '' || + v.beneficiaryPublicKey === '' || + v.arbiterPublicKey !== v.beneficiaryPublicKey, + { + message: 'Arbiter cannot be the same address as the beneficiary', + path: ['arbiterPublicKey'], + }, + ) +} interface EscrowFormProps { onSubmit: (values: EscrowFormValues) => void isLoading?: boolean supportedAssets: { code: string; name: string }[] + depositorPublicKey?: string | null } -export function EscrowForm({ onSubmit, isLoading = false, supportedAssets }: EscrowFormProps) { +export function EscrowForm({ + onSubmit, + isLoading = false, + supportedAssets, + depositorPublicKey = null, +}: EscrowFormProps) { + const escrowSchema = useMemo(() => buildEscrowSchema(depositorPublicKey), [depositorPublicKey]) + const { register, handleSubmit, diff --git a/src/components/ExchangeRate/RateBadge.test.tsx b/src/components/ExchangeRate/RateBadge.test.tsx index 9c50d7f..27f17b6 100644 --- a/src/components/ExchangeRate/RateBadge.test.tsx +++ b/src/components/ExchangeRate/RateBadge.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react" +import { render } from "@testing-library/react" import { describe, it, expect } from "vitest" import { RateBadge } from "./RateBadge" describe("RateBadge", () => { diff --git a/src/components/SendFlow/SendFlow.tsx b/src/components/SendFlow/SendFlow.tsx index 85107bf..7a9522e 100644 --- a/src/components/SendFlow/SendFlow.tsx +++ b/src/components/SendFlow/SendFlow.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react' export function SendFlow() { - const [step, setStep] = useState(0) + const [step] = useState(0) return
Step {step + 1}
} diff --git a/src/components/TransactionHistory/Pagination.test.tsx b/src/components/TransactionHistory/Pagination.test.tsx index f427f29..fe76ac3 100644 --- a/src/components/TransactionHistory/Pagination.test.tsx +++ b/src/components/TransactionHistory/Pagination.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from "@testing-library/react" +import { render, screen } from "@testing-library/react" import { describe, it, expect, vi } from "vitest" import { Pagination } from "./Pagination" describe("Pagination", () => { diff --git a/src/components/common/Skeleton.test.tsx b/src/components/common/Skeleton.test.tsx index 87dcd89..74e0c06 100644 --- a/src/components/common/Skeleton.test.tsx +++ b/src/components/common/Skeleton.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react" +import { render } from "@testing-library/react" import { describe, it, expect } from "vitest" import { Skeleton, TransactionSkeleton } from "./Skeleton" describe("Skeleton", () => { diff --git a/src/components/dashboard/BalanceCard.tsx b/src/components/dashboard/BalanceCard.tsx index 6bba43a..18c934e 100644 --- a/src/components/dashboard/BalanceCard.tsx +++ b/src/components/dashboard/BalanceCard.tsx @@ -7,7 +7,7 @@ import { useWallet } from '@/hooks/useWallet' import { formatXLM, formatAmount } from '@/lib/stellar' export function BalanceCard() { - const { account, xlmBalance, usdcBalance, network, refreshAccount, isConnected } = useWallet() + const { account, xlmBalance, usdcBalance, network, refreshAccount } = useWallet() const [hidden, setHidden] = useState(false) const [refreshing, setRefreshing] = useState(false) @@ -20,7 +20,7 @@ export function BalanceCard() { } } - const mask = (val: string) => '••••••' + const mask = (_val: string) => '••••••' return ( diff --git a/src/components/history/TransactionTable.tsx b/src/components/history/TransactionTable.tsx index 423fc4e..121e23d 100644 --- a/src/components/history/TransactionTable.tsx +++ b/src/components/history/TransactionTable.tsx @@ -2,7 +2,7 @@ import React from 'react' import { Inbox, AlertCircle, ChevronDown, Filter, Search } from 'lucide-react' import { Card, CardHeader, CardTitle } from '@/components/ui/Card' import { Button } from '@/components/ui/Button' -import { SkeletonRow, Spinner } from '@/components/ui/Spinner' +import { SkeletonRow } from '@/components/ui/Spinner' import { TransactionRow } from './TransactionRow' import { useTransactions } from '@/hooks/useTransactions' import { useWallet } from '@/hooks/useWallet' diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx index beaed67..0581c86 100644 --- a/src/components/ui/Badge.tsx +++ b/src/components/ui/Badge.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { CheckCircle, XCircle, Clock, AlertCircle, Loader2 } from 'lucide-react' +import { CheckCircle, XCircle, AlertCircle, Loader2 } from 'lucide-react' import { cn } from '@/lib/utils' import type { TransactionStatus } from '@/types' diff --git a/src/components/wallet/WalletInfo.tsx b/src/components/wallet/WalletInfo.tsx index d565ecb..a25367c 100644 --- a/src/components/wallet/WalletInfo.tsx +++ b/src/components/wallet/WalletInfo.tsx @@ -9,7 +9,7 @@ import { Check, } from 'lucide-react' import { Button } from '@/components/ui/Button' -import { Badge, NetworkBadge } from '@/components/ui/Badge' +import { NetworkBadge } from '@/components/ui/Badge' import { useWallet } from '@/hooks/useWallet' import { truncateAddress, formatXLM, formatAmount } from '@/lib/stellar' import { copyToClipboard } from '@/lib/utils' diff --git a/src/context/WalletContext.tsx b/src/context/WalletContext.tsx index 438c51b..164dcc4 100644 --- a/src/context/WalletContext.tsx +++ b/src/context/WalletContext.tsx @@ -210,6 +210,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { // ─── Hook ───────────────────────────────────────────────────────────────────── +// eslint-disable-next-line react-refresh/only-export-components export function useWalletContext(): WalletContextValue { const ctx = useContext(WalletContext) if (!ctx) throw new Error('useWalletContext must be used inside WalletProvider') diff --git a/src/pages/Escrow.tsx b/src/pages/Escrow.tsx index 2640e2b..483da91 100644 --- a/src/pages/Escrow.tsx +++ b/src/pages/Escrow.tsx @@ -75,7 +75,11 @@ export default function EscrowPage() { )} {(state.step === 'form' || state.step === 'error') && ( - + )} { it('formats with 2 decimal places', () => expect(formatAmount(1234.5)).toBe('1,234.50'))