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'))