From 8e7dd78ed57fd40cac735101fa1e4798f6dfd936 Mon Sep 17 00:00:00 2001 From: garbaaandekuba-source Date: Thu, 22 Oct 2026 00:24:42 +0000 Subject: [PATCH] feat(disputes): add dispute workflow, evidence and authorized resolution API (#247) --- __tests__/disputes.test.ts | 174 ++++++++++++++++++ app/api/contracts/[id]/evidence/route.ts | 38 ++++ .../contracts/disputes/[id]/resolve/route.ts | 87 +++++++++ app/api/contracts/disputes/[id]/route.ts | 29 +++ app/api/contracts/disputes/route.ts | 33 ++++ docs/dispute-resolution-api.md | 26 +++ env.example | 3 + lib/disputes.ts | 91 +++++++++ lib/escrow-chain.ts | 24 +++ scripts/00N_disputes.sql | 48 +++++ 10 files changed, 553 insertions(+) create mode 100644 __tests__/disputes.test.ts create mode 100644 app/api/contracts/[id]/evidence/route.ts create mode 100644 app/api/contracts/disputes/[id]/resolve/route.ts create mode 100644 app/api/contracts/disputes/[id]/route.ts create mode 100644 app/api/contracts/disputes/route.ts create mode 100644 docs/dispute-resolution-api.md create mode 100644 lib/disputes.ts create mode 100644 lib/escrow-chain.ts create mode 100644 scripts/00N_disputes.sql diff --git a/__tests__/disputes.test.ts b/__tests__/disputes.test.ts new file mode 100644 index 0000000..fa78662 --- /dev/null +++ b/__tests__/disputes.test.ts @@ -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) + }) +}) \ No newline at end of file diff --git a/app/api/contracts/[id]/evidence/route.ts b/app/api/contracts/[id]/evidence/route.ts new file mode 100644 index 0000000..fc50d77 --- /dev/null +++ b/app/api/contracts/[id]/evidence/route.ts @@ -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 }) +} \ No newline at end of file diff --git a/app/api/contracts/disputes/[id]/resolve/route.ts b/app/api/contracts/disputes/[id]/resolve/route.ts new file mode 100644 index 0000000..8b919a5 --- /dev/null +++ b/app/api/contracts/disputes/[id]/resolve/route.ts @@ -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 }) +} \ No newline at end of file diff --git a/app/api/contracts/disputes/[id]/route.ts b/app/api/contracts/disputes/[id]/route.ts new file mode 100644 index 0000000..9f5290a --- /dev/null +++ b/app/api/contracts/disputes/[id]/route.ts @@ -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, + }) +} \ No newline at end of file diff --git a/app/api/contracts/disputes/route.ts b/app/api/contracts/disputes/route.ts new file mode 100644 index 0000000..c51dce2 --- /dev/null +++ b/app/api/contracts/disputes/route.ts @@ -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 }) +} \ No newline at end of file diff --git a/docs/dispute-resolution-api.md b/docs/dispute-resolution-api.md new file mode 100644 index 0000000..af057d3 --- /dev/null +++ b/docs/dispute-resolution-api.md @@ -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. \ No newline at end of file diff --git a/env.example b/env.example index ce0bc56..afaeaac 100644 --- a/env.example +++ b/env.example @@ -10,6 +10,9 @@ DATABASE_URL=postgres://user:password@ep-..aws.neon.tech/ne # node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" JWT_SECRET=replace_with_a_long_random_secret_minimum_32_characters +# Comma-separated user ids allowed to resolve disputes (replace if the repo already has an arbitrator/role system) +DISPUTE_ARBITRATOR_IDS= + # ─── Stellar / Soroban ──────────────────────────────────────────────────────── # Horizon RPC endpoint. Defaults to testnet if not set. STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org diff --git a/lib/disputes.ts b/lib/disputes.ts new file mode 100644 index 0000000..2b958c5 --- /dev/null +++ b/lib/disputes.ts @@ -0,0 +1,91 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { sql } from '@/lib/db' // REPLACE if db.ts exports differently +import { escrowChain } from '@/lib/escrow-chain' + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +export const isUuid = (v: string) => UUID.test(v) // REPLACE if contract ids are not uuid + +export const err = (status: number, error: string, message?: string) => + NextResponse.json(message ? { error, message } : { error }, { status }) + +// REPLACE with the statuses that mean "funded / in progress" +export const ELIGIBLE_CONTRACT_STATUSES = ['active', 'funded', 'in_progress'] + +export const createDisputeSchema = z.object({ reason: z.string().trim().min(10).max(5000) }) + +export const evidenceSchema = z + .object({ + kind: z.enum(['url', 'ipfs']), + uri: z.string().trim().max(2048), + description: z.string().trim().max(1000).optional(), + sha256: z.string().regex(/^[0-9a-f]{64}$/i).optional(), + }) + .refine( + (v) => (v.kind === 'url' ? /^https:\/\/\S+$/i.test(v.uri) : /^ipfs:\/\/[A-Za-z0-9]+\S*$/.test(v.uri)), + { message: 'uri does not match kind (https:// for url, ipfs:// for ipfs)' }, + ) + +export const resolveSchema = z + .object({ + outcome: z.enum(['release_to_freelancer', 'refund_to_client', 'split']), + reason: z.string().trim().min(10).max(5000), + split_bps: z.number().int().min(1).max(9999).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + }) + .refine((v) => (v.outcome === 'split') === (v.split_bps !== undefined), { + message: 'split_bps is required for split outcomes and not allowed otherwise', + }) + +export function isArbitrator(userId: string) { + return (process.env.DISPUTE_ARBITRATOR_IDS ?? '') + .split(',').map((s) => s.trim()).filter(Boolean).includes(userId) +} + +// REPLACE column names to match the contracts table +export function isParticipant(c: { client_id: string; freelancer_id: string }, userId: string) { + return c.client_id === userId || c.freelancer_id === userId +} + +/** Syncs an in-flight resolution with the chain. Safe to call repeatedly. */ +export async function reconcileDispute(id: string) { + const [d] = await sql`SELECT * FROM disputes WHERE id = ${id}` + if (!d || d.status !== 'resolution_pending' || !d.resolution_tx_hash) return d + + let txStatus: 'pending' | 'confirmed' | 'failed' + try { + txStatus = await escrowChain.getTxStatus(d.resolution_tx_hash) + } catch { + return d // chain unreachable: stay pending, try again later + } + + if (txStatus === 'confirmed') { + const rows = await sql` + WITH upd AS ( + UPDATE disputes SET status = 'resolved', resolution_confirmed_at = now(), updated_at = now() + WHERE id = ${id} AND status = 'resolution_pending' RETURNING * + ), ev AS ( + INSERT INTO dispute_events (dispute_id, actor_id, event_type, tx_hash) + SELECT id, NULL, 'resolution_confirmed', resolution_tx_hash FROM upd + ) + SELECT * FROM upd` + if (rows.length) return rows[0] + return (await sql`SELECT * FROM disputes WHERE id = ${id}`)[0] + } + + if (txStatus === 'failed') { + const rows = await sql` + WITH upd AS ( + UPDATE disputes SET status = 'resolution_failed', chain_last_error = 'transaction failed on-chain', updated_at = now() + WHERE id = ${id} AND status = 'resolution_pending' RETURNING * + ), ev AS ( + INSERT INTO dispute_events (dispute_id, actor_id, event_type, tx_hash) + SELECT id, NULL, 'resolution_failed', ${d.resolution_tx_hash} FROM upd + ) + SELECT * FROM upd` + if (rows.length) return rows[0] + return (await sql`SELECT * FROM disputes WHERE id = ${id}`)[0] + } + + return d +} \ No newline at end of file diff --git a/lib/escrow-chain.ts b/lib/escrow-chain.ts new file mode 100644 index 0000000..1aeae0f --- /dev/null +++ b/lib/escrow-chain.ts @@ -0,0 +1,24 @@ +export type TxStatus = 'pending' | 'confirmed' | 'failed' +export type Outcome = 'release_to_freelancer' | 'refund_to_client' | 'split' + +export interface EscrowChain { + submitResolution(args: { + escrowContractId: string + disputeId: string + outcome: Outcome + splitBps?: number + }): Promise<{ txHash: string }> + getTxStatus(txHash: string): Promise +} + +// REPLACE: wire both methods to the existing Soroban/Stellar helpers +// (see docs/soroban-escrow-deployment.md). The contract itself must enforce who may +// resolve, so the backend check below can never bypass the contract's own rules. +export const escrowChain: EscrowChain = { + async submitResolution() { + throw new Error('escrow_not_configured') + }, + async getTxStatus() { + throw new Error('escrow_not_configured') + }, +} \ No newline at end of file diff --git a/scripts/00N_disputes.sql b/scripts/00N_disputes.sql new file mode 100644 index 0000000..f2c443a --- /dev/null +++ b/scripts/00N_disputes.sql @@ -0,0 +1,48 @@ +CREATE TABLE IF NOT EXISTS disputes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id uuid NOT NULL REFERENCES contracts(id) ON DELETE CASCADE, -- REPLACE type if ids are not uuid + opened_by uuid NOT NULL, -- REPLACE with users.id type + reason text NOT NULL CHECK (char_length(reason) BETWEEN 10 AND 5000), + status text NOT NULL DEFAULT 'open' + CHECK (status IN ('open','resolution_pending','resolution_failed','resolved')), + resolution_outcome text CHECK (resolution_outcome IN ('release_to_freelancer','refund_to_client','split')), + resolution_reason text, + resolution_metadata jsonb, + resolved_by uuid, + resolution_tx_hash text, + resolution_submitted_at timestamptz, + resolution_confirmed_at timestamptz, + chain_attempts integer NOT NULL DEFAULT 0, + chain_last_error text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- One unresolved dispute per contract (prevents duplicate disputes) +CREATE UNIQUE INDEX IF NOT EXISTS uniq_active_dispute_per_contract + ON disputes (contract_id) WHERE status <> 'resolved'; + +CREATE TABLE IF NOT EXISTS dispute_evidence ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + dispute_id uuid NOT NULL REFERENCES disputes(id) ON DELETE CASCADE, + submitted_by uuid NOT NULL, -- REPLACE type + kind text NOT NULL CHECK (kind IN ('url','ipfs')), + uri text NOT NULL CHECK (char_length(uri) <= 2048), + description text CHECK (description IS NULL OR char_length(description) <= 1000), + sha256 text CHECK (sha256 IS NULL OR sha256 ~ '^[0-9a-fA-F]{64}$'), + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (dispute_id, uri) -- prevents duplicate evidence +); + +-- Audit trail +CREATE TABLE IF NOT EXISTS dispute_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + dispute_id uuid NOT NULL REFERENCES disputes(id) ON DELETE CASCADE, + actor_id uuid, -- NULL = system + event_type text NOT NULL, + tx_hash text, + payload jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_dispute_events_dispute ON dispute_events (dispute_id, created_at); +CREATE INDEX IF NOT EXISTS idx_dispute_evidence_dispute ON dispute_evidence (dispute_id); \ No newline at end of file