Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions __tests__/disputes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest } from 'next/server'

const m = vi.hoisted(() => ({ sql: vi.fn(), getUser: vi.fn(), submit: vi.fn(), txStatus: vi.fn() }))
vi.mock('@/lib/db', () => ({ sql: (...a: unknown[]) => m.sql(...a) })) // REPLACE paths if needed
vi.mock('@/lib/auth', () => ({ getCurrentUser: (...a: unknown[]) => m.getUser(...a) })) // REPLACE
vi.mock('@/lib/escrow-chain', () => ({
escrowChain: { submitResolution: (...a: unknown[]) => m.submit(...a), getTxStatus: (...a: unknown[]) => m.txStatus(...a) },
}))

import { POST as createDispute } from '@/app/api/contracts/[id]/disputes/route'
import { POST as addEvidence } from '@/app/api/disputes/[id]/evidence/route'
import { GET as getDispute } from '@/app/api/disputes/[id]/route'
import { POST as resolve } from '@/app/api/disputes/[id]/resolve/route'

const ID = '11111111-1111-4111-8111-111111111111'
const ctx = { params: Promise.resolve({ id: ID }) }
const post = (body?: unknown) =>
new NextRequest('http://localhost/x', { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) })
const contract = { id: ID, client_id: 'client-1', freelancer_id: 'free-1', status: 'active' }
const REASON = 'The deliverable was never provided.'
const DECISION = { outcome: 'refund_to_client', reason: 'Freelancer did not deliver the work.' }

beforeEach(() => {
m.sql.mockReset(); m.getUser.mockReset(); m.submit.mockReset(); m.txStatus.mockReset()
m.getUser.mockResolvedValue({ id: 'client-1' })
process.env.DISPUTE_ARBITRATOR_IDS = 'arb-1'
})

describe('create dispute', () => {
it('401 when unauthenticated', async () => {
m.getUser.mockResolvedValue(null)
expect((await createDispute(post({ reason: REASON }), ctx)).status).toBe(401)
})
it('400 on short reason', async () => {
expect((await createDispute(post({ reason: 'short' }), ctx)).status).toBe(400)
})
it('403 for non-participants', async () => {
m.getUser.mockResolvedValue({ id: 'stranger' })
m.sql.mockResolvedValueOnce([contract])
expect((await createDispute(post({ reason: REASON }), ctx)).status).toBe(403)
})
it('409 when the contract status is not eligible', async () => {
m.sql.mockResolvedValueOnce([{ ...contract, status: 'completed' }])
expect((await createDispute(post({ reason: REASON }), ctx)).status).toBe(409)
})
it('409 on duplicate open dispute', async () => {
m.sql.mockResolvedValueOnce([contract]).mockResolvedValueOnce([])
const res = await createDispute(post({ reason: REASON }), ctx)
expect(res.status).toBe(409)
expect((await res.json()).error).toBe('dispute_already_open')
})
it('201 creates a dispute', async () => {
m.sql.mockResolvedValueOnce([contract]).mockResolvedValueOnce([{ id: 'd1', status: 'open' }])
expect((await createDispute(post({ reason: REASON }), ctx)).status).toBe(201)
})
})

describe('evidence', () => {
const body = { kind: 'url', uri: 'https://example.com/proof.png' }
it('rejects a bad uri scheme (400)', async () => {
expect((await addEvidence(post({ kind: 'url', uri: 'http://x.com' }), ctx)).status).toBe(400)
})
it('403 for non-participants', async () => {
m.getUser.mockResolvedValue({ id: 'stranger' })
m.sql.mockResolvedValueOnce([{ id: ID, status: 'open', client_id: 'client-1', freelancer_id: 'free-1' }])
expect((await addEvidence(post(body), ctx)).status).toBe(403)
})
it('409 when the dispute is no longer open', async () => {
m.sql.mockResolvedValueOnce([{ id: ID, status: 'resolved', client_id: 'client-1', freelancer_id: 'free-1' }])
expect((await addEvidence(post(body), ctx)).status).toBe(409)
})
it('201 stores new evidence', async () => {
m.sql.mockResolvedValueOnce([{ id: ID, status: 'open', client_id: 'client-1', freelancer_id: 'free-1' }])
.mockResolvedValueOnce([{ id: 'e1' }])
expect((await addEvidence(post(body), ctx)).status).toBe(201)
})
it('duplicate evidence returns the existing record, no new one', async () => {
m.sql.mockResolvedValueOnce([{ id: ID, status: 'open', client_id: 'client-1', freelancer_id: 'free-1' }])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 'e1' }])
const res = await addEvidence(post(body), ctx)
expect(res.status).toBe(200)
expect((await res.json()).duplicate).toBe(true)
})
})

describe('get dispute', () => {
it('403 for users outside the contract', async () => {
m.getUser.mockResolvedValue({ id: 'stranger' })
m.sql.mockResolvedValueOnce([{ client_id: 'client-1', freelancer_id: 'free-1' }])
expect((await getDispute(new NextRequest('http://localhost/x'), ctx)).status).toBe(403)
})
})

describe('resolve', () => {
const row = { id: ID, status: 'open', client_id: 'client-1', freelancer_id: 'free-1', escrow_contract_id: 'C123' }

it('403 for non-arbitrators', async () => {
m.sql.mockResolvedValueOnce([row])
expect((await resolve(post(DECISION), ctx)).status).toBe(403)
})
it('403 when the arbitrator is a participant (conflict of interest)', async () => {
m.getUser.mockResolvedValue({ id: 'arb-1' })
m.sql.mockResolvedValueOnce([{ ...row, client_id: 'arb-1' }])
const res = await resolve(post(DECISION), ctx)
expect(res.status).toBe(403)
expect((await res.json()).error).toBe('conflict_of_interest')
})
it('400 when split has no split_bps', async () => {
m.getUser.mockResolvedValue({ id: 'arb-1' })
expect((await resolve(post({ ...DECISION, outcome: 'split' }), ctx)).status).toBe(400)
})
it('409 when already resolved (claim finds nothing)', async () => {
m.getUser.mockResolvedValue({ id: 'arb-1' })
m.sql.mockResolvedValueOnce([row]).mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: ID, status: 'resolved' }])
const res = await resolve(post(DECISION), ctx)
expect(res.status).toBe(409)
expect((await res.json()).error).toBe('already_resolved')
expect(m.submit).not.toHaveBeenCalled()
})
it('200: claims, submits once and confirms', async () => {
m.getUser.mockResolvedValue({ id: 'arb-1' })
m.submit.mockResolvedValue({ txHash: 'abc' })
m.txStatus.mockResolvedValue('confirmed')
m.sql
.mockResolvedValueOnce([row]) // load
.mockResolvedValueOnce([{ id: ID, status: 'resolution_pending' }]) // claim
.mockResolvedValueOnce([]) // store tx hash
.mockResolvedValueOnce([{ id: ID, status: 'resolution_pending', resolution_tx_hash: 'abc' }]) // reconcile select
.mockResolvedValueOnce([{ id: ID, status: 'resolved' }]) // reconcile update
const res = await resolve(post(DECISION), ctx)
expect(res.status).toBe(200)
expect(m.submit).toHaveBeenCalledTimes(1)
expect(m.submit).toHaveBeenCalledWith({ escrowContractId: 'C123', disputeId: ID, outcome: 'refund_to_client', splitBps: undefined })
})
it('202 while the chain transaction is still pending', async () => {
m.getUser.mockResolvedValue({ id: 'arb-1' })
m.submit.mockResolvedValue({ txHash: 'abc' })
m.txStatus.mockResolvedValue('pending')
m.sql
.mockResolvedValueOnce([row]).mockResolvedValueOnce([{ id: ID }]).mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: ID, status: 'resolution_pending', resolution_tx_hash: 'abc' }])
expect((await resolve(post(DECISION), ctx)).status).toBe(202)
})
it('502 and recoverable state when submission fails', async () => {
m.getUser.mockResolvedValue({ id: 'arb-1' })
m.submit.mockRejectedValue(new Error('rpc down'))
m.sql.mockResolvedValueOnce([row]).mockResolvedValueOnce([{ id: ID }]).mockResolvedValueOnce([])
const res = await resolve(post(DECISION), ctx)
expect(res.status).toBe(502)
expect(JSON.stringify(m.sql.mock.calls.at(-1))).toContain('resolution_failed')
})
it('concurrent resolves: exactly one submits to the chain', async () => {
m.getUser.mockResolvedValue({ id: 'arb-1' })
m.submit.mockResolvedValue({ txHash: 'abc' })
m.txStatus.mockResolvedValue('confirmed')
let claimedOnce = false
m.sql.mockImplementation(async (strings: TemplateStringsArray) => {
const q = strings.join('?')
if (q.includes('JOIN contracts')) return [row]
if (q.includes('claimed AS')) {
if (claimedOnce) return []
claimedOnce = true
return [{ id: ID, status: 'resolution_pending' }]
}
if (q.includes('SELECT * FROM disputes')) return [{ id: ID, status: 'resolved' }]
return []
})
const results = await Promise.all([resolve(post(DECISION), ctx), resolve(post(DECISION), ctx)])
expect(m.submit).toHaveBeenCalledTimes(1)
expect(results.map((r) => r.status).sort()).toContain(409)
})
})
38 changes: 38 additions & 0 deletions app/api/contracts/[id]/evidence/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import { getCurrentUser } from '@/lib/auth' // REPLACE
import { sql } from '@/lib/db'
import { evidenceSchema, isUuid, isParticipant, err } from '@/lib/disputes'

export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
if (!isUuid(id)) return err(400, 'invalid_dispute_id')
const user = await getCurrentUser(req)
if (!user) return err(401, 'unauthorized')

const parsed = evidenceSchema.safeParse(await req.json().catch(() => null))
if (!parsed.success) return err(400, 'validation_error', parsed.error.issues[0]?.message)

const [d] = await sql`
SELECT d.id, d.status, c.client_id, c.freelancer_id
FROM disputes d JOIN contracts c ON c.id = d.contract_id WHERE d.id = ${id}` // REPLACE columns
if (!d) return err(404, 'dispute_not_found')
if (!isParticipant(d, user.id)) return err(403, 'forbidden')
if (d.status !== 'open') return err(409, 'dispute_not_open')

const { kind, uri, description, sha256 } = parsed.data
const rows = await sql`
WITH ins AS (
INSERT INTO dispute_evidence (dispute_id, submitted_by, kind, uri, description, sha256)
VALUES (${id}, ${user.id}, ${kind}, ${uri}, ${description ?? null}, ${sha256 ?? null})
ON CONFLICT (dispute_id, uri) DO NOTHING RETURNING *
), ev AS (
INSERT INTO dispute_events (dispute_id, actor_id, event_type, payload)
SELECT dispute_id, ${user.id}, 'evidence_added', jsonb_build_object('evidence_id', id, 'kind', kind) FROM ins
)
SELECT * FROM ins`
if (rows.length) return NextResponse.json({ evidence: rows[0] }, { status: 201 })

// Duplicate submission: return the existing record, create nothing new
const [existing] = await sql`SELECT * FROM dispute_evidence WHERE dispute_id = ${id} AND uri = ${uri}`
return NextResponse.json({ evidence: existing, duplicate: true }, { status: 200 })
}
87 changes: 87 additions & 0 deletions app/api/contracts/disputes/[id]/resolve/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server'
import { getCurrentUser } from '@/lib/auth' // REPLACE
import { sql } from '@/lib/db'
import { escrowChain } from '@/lib/escrow-chain'
import { resolveSchema, isUuid, isParticipant, isArbitrator, reconcileDispute, err } from '@/lib/disputes'

export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
if (!isUuid(id)) return err(400, 'invalid_dispute_id')
const user = await getCurrentUser(req)
if (!user) return err(401, 'unauthorized')

const parsed = resolveSchema.safeParse(await req.json().catch(() => null))
if (!parsed.success) return err(400, 'validation_error', parsed.error.issues[0]?.message)
const { outcome, reason, split_bps, metadata } = parsed.data

const [d] = await sql`
SELECT d.id, d.status, c.client_id, c.freelancer_id, c.escrow_contract_id
FROM disputes d JOIN contracts c ON c.id = d.contract_id WHERE d.id = ${id}` // REPLACE columns
if (!d) return err(404, 'dispute_not_found')
if (!isArbitrator(user.id)) return err(403, 'forbidden')
if (isParticipant(d, user.id)) return err(403, 'conflict_of_interest')

const meta = JSON.stringify({ ...(metadata ?? {}), ...(split_bps ? { split_bps } : {}) })

// Atomic claim: only one caller can move the dispute into resolution_pending.
// A failed attempt, or a pending one that never submitted (crash) for 5+ minutes, can be retried
// with the same outcome only.
const claimed = await sql`
WITH claimed AS (
UPDATE disputes SET
status = 'resolution_pending', resolution_outcome = ${outcome}, resolution_reason = ${reason},
resolution_metadata = ${meta}::jsonb, resolved_by = ${user.id},
chain_attempts = chain_attempts + 1, updated_at = now()
WHERE id = ${id}
AND (resolution_outcome IS NULL OR resolution_outcome = ${outcome})
AND (
status IN ('open', 'resolution_failed')
OR (status = 'resolution_pending' AND resolution_tx_hash IS NULL AND updated_at < now() - interval '5 minutes')
)
RETURNING *
), ev AS (
INSERT INTO dispute_events (dispute_id, actor_id, event_type, payload)
SELECT id, ${user.id}, 'resolution_started',
jsonb_build_object('outcome', ${outcome}, 'attempt', chain_attempts) FROM claimed
)
SELECT * FROM claimed`

if (!claimed.length) {
const current = await reconcileDispute(id)
if (current.status === 'resolved') return err(409, 'already_resolved')
if (current.status === 'resolution_pending') {
return NextResponse.json({ dispute: current, status: 'pending_confirmation' }, { status: 202 })
}
return err(409, 'resolution_conflict', 'A different decision is already in progress for this dispute')
}

try {
const { txHash } = await escrowChain.submitResolution({
escrowContractId: d.escrow_contract_id,
disputeId: id,
outcome,
splitBps: split_bps,
})
await sql`
WITH upd AS (
UPDATE disputes SET resolution_tx_hash = ${txHash}, resolution_submitted_at = now(), updated_at = now()
WHERE id = ${id} AND status = 'resolution_pending' RETURNING id
)
INSERT INTO dispute_events (dispute_id, actor_id, event_type, tx_hash)
SELECT id, ${user.id}, 'resolution_submitted', ${txHash} FROM upd`
} catch (e) {
const message = e instanceof Error ? e.message.slice(0, 500) : 'unknown_error'
await sql`
WITH upd AS (
UPDATE disputes SET status = 'resolution_failed', chain_last_error = ${message}, updated_at = now()
WHERE id = ${id} AND status = 'resolution_pending' RETURNING id
)
INSERT INTO dispute_events (dispute_id, actor_id, event_type, payload)
SELECT id, ${user.id}, 'resolution_failed', jsonb_build_object('error', ${message}) FROM upd`
return err(502, 'chain_submission_failed', 'The resolution can be retried')
}

const dispute = await reconcileDispute(id)
if (dispute.status === 'resolved') return NextResponse.json({ dispute }, { status: 200 })
return NextResponse.json({ dispute, status: 'pending_confirmation' }, { status: 202 })
}
29 changes: 29 additions & 0 deletions app/api/contracts/disputes/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server'
import { getCurrentUser } from '@/lib/auth' // REPLACE
import { sql } from '@/lib/db'
import { isUuid, isParticipant, isArbitrator, reconcileDispute, err } from '@/lib/disputes'

export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
if (!isUuid(id)) return err(400, 'invalid_dispute_id')
const user = await getCurrentUser(req)
if (!user) return err(401, 'unauthorized')

const [access] = await sql`
SELECT c.client_id, c.freelancer_id
FROM disputes d JOIN contracts c ON c.id = d.contract_id WHERE d.id = ${id}` // REPLACE columns
if (!access) return err(404, 'dispute_not_found')
const arbitrator = isArbitrator(user.id)
if (!isParticipant(access, user.id) && !arbitrator) return err(403, 'forbidden')

const dispute = await reconcileDispute(id) // keeps app state in sync with the chain
const evidence = await sql`SELECT * FROM dispute_evidence WHERE dispute_id = ${id} ORDER BY created_at`
const events = await sql`SELECT * FROM dispute_events WHERE dispute_id = ${id} ORDER BY created_at`

const { chain_last_error, ...safe } = dispute
return NextResponse.json({
dispute: arbitrator ? dispute : safe,
evidence,
events,
})
}
33 changes: 33 additions & 0 deletions app/api/contracts/disputes/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server'
import { getCurrentUser } from '@/lib/auth' // REPLACE
import { sql } from '@/lib/db'
import { createDisputeSchema, isUuid, isParticipant, err, ELIGIBLE_CONTRACT_STATUSES } from '@/lib/disputes'

export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
if (!isUuid(id)) return err(400, 'invalid_contract_id')
const user = await getCurrentUser(req)
if (!user) return err(401, 'unauthorized')

const parsed = createDisputeSchema.safeParse(await req.json().catch(() => null))
if (!parsed.success) return err(400, 'validation_error', parsed.error.issues[0]?.message)

const [contract] = await sql`SELECT id, client_id, freelancer_id, status FROM contracts WHERE id = ${id}` // REPLACE columns
if (!contract) return err(404, 'contract_not_found')
if (!isParticipant(contract, user.id)) return err(403, 'forbidden')
if (!ELIGIBLE_CONTRACT_STATUSES.includes(contract.status)) return err(409, 'contract_not_eligible')

const rows = await sql`
WITH ins AS (
INSERT INTO disputes (contract_id, opened_by, reason)
VALUES (${id}, ${user.id}, ${parsed.data.reason})
ON CONFLICT (contract_id) WHERE status <> 'resolved' DO NOTHING
RETURNING *
), ev AS (
INSERT INTO dispute_events (dispute_id, actor_id, event_type)
SELECT id, ${user.id}, 'created' FROM ins
)
SELECT * FROM ins`
if (!rows.length) return err(409, 'dispute_already_open')
return NextResponse.json({ dispute: rows[0] }, { status: 201 })
}
26 changes: 26 additions & 0 deletions docs/dispute-resolution-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Dispute Resolution API

| Method | Path | Who | Purpose |
| --- | --- | --- | --- |
| POST | `/api/contracts/:id/disputes` | Contract participants | Open a dispute (body: `reason`) |
| POST | `/api/disputes/:id/evidence` | Contract participants | Add evidence (`kind`: `url`\|`ipfs`, `uri`, optional `description`, `sha256`) |
| GET | `/api/disputes/:id` | Participants and arbitrators | Dispute, evidence and audit events |
| POST | `/api/disputes/:id/resolve` | Arbitrators (not parties to the contract) | Decide (`outcome`, `reason`, optional `split_bps`, `metadata`) |

## Statuses
`open` → `resolution_pending` (tx submitted, not yet confirmed) → `resolved` (confirmed on-chain).
`resolution_failed` means submission or the transaction failed; call resolve again with the same outcome to retry.

## Rules
- One unresolved dispute per contract; the contract must be in an eligible status.
- A dispute can be resolved only once; duplicate calls return `409 already_resolved` or `202` while confirming.
- Evidence is deduplicated per dispute by `uri`.
- Every lifecycle step is stored in `dispute_events` (with tx hash where relevant).
- The escrow contract enforces its own authorization; backend checks never replace it.
- `GET /api/disputes/:id` re-checks pending transactions, keeping app state in sync with the chain.

## Errors
`400` validation, `401` unauthorized, `403` forbidden / conflict_of_interest, `404` not found, `409` dispute_already_open / contract_not_eligible / dispute_not_open / already_resolved / resolution_conflict, `502` chain_submission_failed (retryable).

## Config
`DISPUTE_ARBITRATOR_IDS`: comma-separated user ids allowed to resolve disputes.
Loading
Loading