Skip to content
Merged
19 changes: 19 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -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: '^_' }],
},
}
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
105 changes: 105 additions & 0 deletions src/components/Escrow/EscrowForm.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<EscrowForm
onSubmit={vi.fn()}
supportedAssets={supportedAssets}
depositorPublicKey={DEPOSITOR}
/>,
)

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(
<EscrowForm
onSubmit={vi.fn()}
supportedAssets={supportedAssets}
depositorPublicKey={DEPOSITOR}
/>,
)

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(
<EscrowForm
onSubmit={vi.fn()}
supportedAssets={supportedAssets}
depositorPublicKey={DEPOSITOR}
/>,
)

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(
<EscrowForm
onSubmit={onSubmit}
supportedAssets={supportedAssets}
depositorPublicKey={DEPOSITOR}
/>,
)

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()
})
})
86 changes: 62 additions & 24 deletions src/components/Escrow/EscrowForm.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/components/ExchangeRate/RateBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/SendFlow/SendFlow.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState } from 'react'
export function SendFlow() {
const [step, setStep] = useState(0)
const [step] = useState(0)
return <div data-testid='send-flow'>Step {step + 1}</div>
}
2 changes: 1 addition & 1 deletion src/components/TransactionHistory/Pagination.test.tsx
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/common/Skeleton.test.tsx
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
4 changes: 2 additions & 2 deletions src/components/dashboard/BalanceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -20,7 +20,7 @@ export function BalanceCard() {
}
}

const mask = (val: string) => '••••••'
const mask = (_val: string) => '••••••'

return (
<Card glow className="relative overflow-hidden">
Expand Down
2 changes: 1 addition & 1 deletion src/components/history/TransactionTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/Badge.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
2 changes: 1 addition & 1 deletion src/components/wallet/WalletInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions src/context/WalletContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
6 changes: 5 additions & 1 deletion src/pages/Escrow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ export default function EscrowPage() {
)}

{(state.step === 'form' || state.step === 'error') && (
<EscrowForm onSubmit={reviewEscrow} supportedAssets={supportedAssets} />
<EscrowForm
onSubmit={reviewEscrow}
supportedAssets={supportedAssets}
depositorPublicKey={publicKey}
/>
)}

<EscrowList
Expand Down
2 changes: 0 additions & 2 deletions src/pages/History.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import { ConnectWalletScreen } from '@/components/wallet/ConnectWallet'
import { TransactionTable } from '@/components/history/TransactionTable'
import { useWallet } from '@/hooks/useWallet'
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Expand Down
7 changes: 4 additions & 3 deletions src/pages/Settings.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react'
import {
Settings as SettingsIcon,
Globe,
Sliders,
Bell,
Expand All @@ -15,7 +14,7 @@ import {
import { Card, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Input, Select } from '@/components/ui/Input'
import { Badge, NetworkBadge } from '@/components/ui/Badge'
import { NetworkBadge } from '@/components/ui/Badge'
import { ConnectWallet } from '@/components/wallet/ConnectWallet'
import { useWallet } from '@/hooks/useWallet'
import { truncateAddress, formatXLM } from '@/lib/stellar'
Expand All @@ -28,7 +27,9 @@ function useSettings() {
try {
const saved = localStorage.getItem('stellarsend_settings')
if (saved) return { ...DEFAULT_SETTINGS, ...JSON.parse(saved) }
} catch {}
} catch {
// corrupted or unavailable localStorage — fall back to defaults
}
return DEFAULT_SETTINGS
})

Expand Down
2 changes: 1 addition & 1 deletion src/utils/format.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { formatAmount, formatDate, formatCurrency } from './format'
import { formatAmount, formatCurrency } from './format'

describe('formatAmount', () => {
it('formats with 2 decimal places', () => expect(formatAmount(1234.5)).toBe('1,234.50'))
Expand Down
Loading