From d61ed564d8aad607e8a075f8c9743a43b559f6c0 Mon Sep 17 00:00:00 2001 From: Amarjeet Date: Tue, 28 Jul 2026 11:07:30 +0530 Subject: [PATCH] feat: implement dispute resolution functionality with distribution management --- .../get-multiple-escrow-balance/route.ts | 38 +++ .../v2/resolve-dispute/route.ts | 80 +++++++ p2p-safe-swap/app/escrow/[id]/admin/page.tsx | 18 +- .../components/escrow/ResolveDisputePanel.tsx | 223 ++++++++++++++++++ .../frontend/components/escrow/types/index.ts | 8 +- .../frontend/lib/escrow-dispute-resolution.ts | 177 ++++++++++++++ 6 files changed, 539 insertions(+), 5 deletions(-) create mode 100644 p2p-safe-swap/app/api/escrow/helper/get-multiple-escrow-balance/route.ts create mode 100644 p2p-safe-swap/app/api/escrow/single-release/v2/resolve-dispute/route.ts create mode 100644 p2p-safe-swap/frontend/components/escrow/ResolveDisputePanel.tsx create mode 100644 p2p-safe-swap/frontend/lib/escrow-dispute-resolution.ts diff --git a/p2p-safe-swap/app/api/escrow/helper/get-multiple-escrow-balance/route.ts b/p2p-safe-swap/app/api/escrow/helper/get-multiple-escrow-balance/route.ts new file mode 100644 index 0000000..9a02e17 --- /dev/null +++ b/p2p-safe-swap/app/api/escrow/helper/get-multiple-escrow-balance/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { trustlessWork, TrustlessWorkApiError } from "@/lib/trustless-work"; + +function getErrorResponse(error: unknown) { + if (error instanceof TrustlessWorkApiError) { + return NextResponse.json({ error: error.details }, { status: error.status }); + } + + const message = error instanceof Error ? error.message : "Unable to fetch escrow balances"; + return NextResponse.json({ error: message }, { status: 500 }); +} + +export async function POST(request: NextRequest) { + let body: { addresses?: unknown }; + + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 }); + } + + if (!Array.isArray(body.addresses) || body.addresses.length === 0) { + return NextResponse.json({ error: "addresses is required" }, { status: 400 }); + } + + const addresses = body.addresses.filter((address): address is string => typeof address === "string" && address.trim().length > 0); + + if (addresses.length === 0) { + return NextResponse.json({ error: "addresses must contain at least one non-empty contract ID" }, { status: 400 }); + } + + try { + const data = await trustlessWork.helper.getMultipleEscrowBalance(addresses); + return NextResponse.json(data); + } catch (error) { + return getErrorResponse(error); + } +} diff --git a/p2p-safe-swap/app/api/escrow/single-release/v2/resolve-dispute/route.ts b/p2p-safe-swap/app/api/escrow/single-release/v2/resolve-dispute/route.ts new file mode 100644 index 0000000..233b43b --- /dev/null +++ b/p2p-safe-swap/app/api/escrow/single-release/v2/resolve-dispute/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + trustlessWork, + TrustlessWorkApiError, + type ResolveDisputeRequest, +} from "@/lib/trustless-work"; + +function getErrorResponse(error: unknown) { + if (error instanceof TrustlessWorkApiError) { + return NextResponse.json({ error: error.details }, { status: error.status }); + } + + const message = error instanceof Error ? error.message : "Unable to resolve dispute"; + return NextResponse.json({ error: message }, { status: 500 }); +} + +function isString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isFinitePositiveNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +export async function POST(request: NextRequest) { + let body: Partial & { + distributions?: Array<{ address?: unknown; amount?: unknown }>; + }; + + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 }); + } + + if (!isString(body.contractId) || !isString(body.disputeResolver)) { + return NextResponse.json( + { error: "contractId and disputeResolver are required" }, + { status: 400 } + ); + } + + if (!Array.isArray(body.distributions) || body.distributions.length === 0) { + return NextResponse.json( + { error: "distributions must include at least one recipient" }, + { status: 400 } + ); + } + + const distributions = body.distributions.map((distribution) => ({ + address: typeof distribution.address === "string" ? distribution.address.trim() : "", + amount: typeof distribution.amount === "number" ? distribution.amount : Number(distribution.amount), + })); + + if (distributions.some((distribution) => !distribution.address || !isFinitePositiveNumber(distribution.amount))) { + return NextResponse.json( + { error: "Each distribution requires a non-empty address and positive amount" }, + { status: 400 } + ); + } + + try { + const data = await trustlessWork.escrow.resolveDispute({ + contractId: body.contractId, + disputeResolver: body.disputeResolver, + distributions, + }); + + if (!data.unsignedTransaction) { + return NextResponse.json( + { error: "Resolution transaction was not returned by the escrow service" }, + { status: 502 } + ); + } + + return NextResponse.json({ unsignedXdr: data.unsignedTransaction }); + } catch (error) { + return getErrorResponse(error); + } +} diff --git a/p2p-safe-swap/app/escrow/[id]/admin/page.tsx b/p2p-safe-swap/app/escrow/[id]/admin/page.tsx index 5c369c4..c752789 100644 --- a/p2p-safe-swap/app/escrow/[id]/admin/page.tsx +++ b/p2p-safe-swap/app/escrow/[id]/admin/page.tsx @@ -1,12 +1,13 @@ "use client"; -import { use } from "react"; +import { use, useState } from "react"; import { EscrowAdminUpdateForm } from "@/frontend/components/escrow/EscrowAdminUpdateForm"; +import { ResolveDisputePanel } from "@/frontend/components/escrow/ResolveDisputePanel"; import type { Escrow } from "@/frontend/components/escrow/types"; const MOCK_ESCROW: Escrow = { contractId: "esc-diego-v", - status: "unfunded", + status: "funded", amount: 1500, currency: "USDC", platformFee: 1.5, @@ -25,6 +26,7 @@ const MOCK_ESCROW: Escrow = { }; const MOCK_IS_ADMIN = true; +const MOCK_IS_MODERATOR = true; interface EscrowAdminPageProps { params: Promise<{ id: string }>; @@ -32,10 +34,10 @@ interface EscrowAdminPageProps { export default function EscrowAdminPage({ params }: EscrowAdminPageProps) { const { id } = use(params); - const escrow: Escrow = { ...MOCK_ESCROW, contractId: id }; + const [escrow, setEscrow] = useState({ ...MOCK_ESCROW, contractId: id }); return ( -
+
+ { + setEscrow(updatedEscrow); + }} + />
); } diff --git a/p2p-safe-swap/frontend/components/escrow/ResolveDisputePanel.tsx b/p2p-safe-swap/frontend/components/escrow/ResolveDisputePanel.tsx new file mode 100644 index 0000000..ef4f5a0 --- /dev/null +++ b/p2p-safe-swap/frontend/components/escrow/ResolveDisputePanel.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { AlertCircle, Plus, ShieldCheck, Trash2 } from "lucide-react"; +import { Button } from "@/frontend/components/ui/Button/Button"; +import type { Escrow, EscrowDistribution, EscrowStatus } from "./types"; +import { + EscrowDisputeResolutionError, + resolveEscrowDispute, + type EscrowDisputeResolutionStatus, + type SignEscrowTransaction, +} from "@/frontend/lib/escrow-dispute-resolution"; + +function createDefaultSignTransaction(): SignEscrowTransaction { + return async () => { + throw new Error("Wallet signing is not yet integrated. Please connect a Stellar wallet."); + }; +} + +export interface ResolveDisputePanelProps { + escrow: Escrow; + isModerator: boolean; + currentWalletAddress?: string; + signTransaction?: SignEscrowTransaction; + onResolved?: (escrow: Escrow) => void; +} + +export function ResolveDisputePanel({ + escrow, + isModerator, + currentWalletAddress, + signTransaction, + onResolved, +}: ResolveDisputePanelProps) { + const [distributions, setDistributions] = useState([]); + const [newAddress, setNewAddress] = useState(""); + const [newAmount, setNewAmount] = useState(""); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + + const totalDistribution = useMemo( + () => distributions.reduce((sum, distribution) => sum + distribution.amount, 0), + [distributions] + ); + + const canSubmit = + isResolver && + distributions.length > 0 && + status !== "requesting-signature" && + status !== "submitting"; + const isResolver = + isModerator || + (Boolean(currentWalletAddress?.trim()) && + Boolean(escrow.roles.disputeResolver?.trim()) && + currentWalletAddress?.trim() === escrow.roles.disputeResolver.trim()); + + function addDistribution() { + const trimmedAddress = newAddress.trim(); + const parsedAmount = Number(newAmount); + + if (!trimmedAddress || Number.isNaN(parsedAmount) || parsedAmount <= 0) { + setError("Add a recipient address and a positive amount before adding a distribution"); + return; + } + + setDistributions((prev) => [...prev, { address: trimmedAddress, amount: parsedAmount }]); + setNewAddress(""); + setNewAmount(""); + setError(null); + } + + function removeDistribution(index: number) { + setDistributions((prev) => prev.filter((_, itemIndex) => itemIndex !== index)); + } + + async function handleResolve() { + if (!isResolver) { + setError("Only the dispute resolver wallet can resolve this escrow"); + return; + } + + setError(null); + + try { + await resolveEscrowDispute( + { + contractId: escrow.contractId, + disputeResolver: escrow.roles.disputeResolver, + distributions, + }, + signTransaction ?? createDefaultSignTransaction(), + setStatus + ); + + onResolved?.({ + ...escrow, + status: "resolved" as EscrowStatus, + resolutionDistributions: distributions, + }); + } catch (submissionError) { + const message = + submissionError instanceof EscrowDisputeResolutionError + ? submissionError.message + : "Unable to resolve the dispute"; + setError(message); + setStatus("failed"); + } + } + + if (!isResolver) { + return null; + } + + return ( +
+
+
+
+
+

Resolve dispute

+

+ Distribute the escrow balance to the resolved recipients and submit the moderator transaction. +

+
+
+ +
+ + setNewAddress(event.target.value)} + placeholder="Stellar address" + className="w-full rounded-xl border border-border bg-transparent px-3 py-2 text-sm text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + + + setNewAmount(event.target.value)} + placeholder="0.00" + className="w-full rounded-xl border border-border bg-transparent px-3 py-2 text-sm text-foreground tabular-nums focus:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + + +
+ + {distributions.length > 0 && ( +
+ {distributions.map((distribution, index) => ( +
+
+ {distribution.address} + {distribution.amount} +
+ +
+ ))} +
+ )} + +
+ Total distribution + {totalDistribution.toFixed(2)} +
+ + {escrow.status === "resolved" && escrow.resolutionDistributions?.length ? ( +
+ Resolved distribution + {escrow.resolutionDistributions.map((distribution, index) => ( +
+ {distribution.address} + {distribution.amount} +
+ ))} +
+ ) : null} + + {error && ( +
+
+ )} + +
+ ); +} diff --git a/p2p-safe-swap/frontend/components/escrow/types/index.ts b/p2p-safe-swap/frontend/components/escrow/types/index.ts index de0eb37..93f3630 100644 --- a/p2p-safe-swap/frontend/components/escrow/types/index.ts +++ b/p2p-safe-swap/frontend/components/escrow/types/index.ts @@ -1,4 +1,9 @@ -export type EscrowStatus = "unfunded" | "funded"; +export type EscrowStatus = "unfunded" | "funded" | "resolved"; + +export interface EscrowDistribution { + address: string; + amount: number; +} export interface EscrowRoles { approver: string; @@ -23,6 +28,7 @@ export interface Escrow { platformFee: number; roles: EscrowRoles; milestones: EscrowMilestone[]; + resolutionDistributions?: EscrowDistribution[]; } export interface EscrowAdminUpdateFormProps { diff --git a/p2p-safe-swap/frontend/lib/escrow-dispute-resolution.ts b/p2p-safe-swap/frontend/lib/escrow-dispute-resolution.ts new file mode 100644 index 0000000..be804b2 --- /dev/null +++ b/p2p-safe-swap/frontend/lib/escrow-dispute-resolution.ts @@ -0,0 +1,177 @@ +export type EscrowDisputeResolutionStatus = + | "idle" + | "requesting-signature" + | "submitting" + | "resolved" + | "failed"; + +export interface EscrowDisputeDistribution { + address: string; + amount: number; +} + +export interface ResolveEscrowDisputeInput { + contractId: string; + disputeResolver: string; + distributions: EscrowDisputeDistribution[]; +} + +export type SignEscrowTransaction = (unsignedXdr: string) => Promise; + +export class EscrowDisputeResolutionError extends Error { + constructor(message: string) { + super(message); + this.name = "EscrowDisputeResolutionError"; + } +} + +interface ResolveDisputeResponse { + unsignedXdr: string; +} + +interface ErrorResponse { + error?: string; +} + +async function readError(response: Response): Promise { + const fallback = `Request failed (${response.status})`; + + try { + const body = (await response.json()) as ErrorResponse; + return body.error || fallback; + } catch { + return fallback; + } +} + +function isFinitePositiveNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +function validateInput({ contractId, disputeResolver, distributions }: ResolveEscrowDisputeInput) { + if (!contractId.trim() || !disputeResolver.trim()) { + throw new EscrowDisputeResolutionError("A contract ID and dispute resolver wallet are required"); + } + + if (!Array.isArray(distributions) || distributions.length === 0) { + throw new EscrowDisputeResolutionError("At least one distribution recipient is required"); + } + + distributions.forEach((distribution, index) => { + if (!distribution.address.trim()) { + throw new EscrowDisputeResolutionError(`Distribution ${index + 1} is missing a recipient address`); + } + + if (!isFinitePositiveNumber(distribution.amount)) { + throw new EscrowDisputeResolutionError(`Distribution ${index + 1} must use a positive amount`); + } + }); +} + +function toNumber(value: unknown): number { + return typeof value === "number" ? value : Number(value); +} + +function asBalanceValue(payload: unknown): number { + if (Array.isArray(payload)) { + return toNumber(payload[0] ?? 0); + } + + if (payload && typeof payload === "object") { + const record = payload as Record; + + for (const candidate of [record.balance, record.amount, record.value, record.total]) { + if (typeof candidate === "number" && Number.isFinite(candidate)) { + return candidate; + } + + if (typeof candidate === "string" && candidate.trim()) { + const parsed = Number(candidate); + if (Number.isFinite(parsed)) return parsed; + } + } + } + + return 0; +} + +export async function resolveEscrowDispute( + input: ResolveEscrowDisputeInput, + signTransaction: SignEscrowTransaction, + onStatusChange?: (status: EscrowDisputeResolutionStatus) => void +): Promise { + validateInput(input); + onStatusChange?.("requesting-signature"); + + const balanceResponse = await fetch("/api/escrow/helper/get-multiple-escrow-balance", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ addresses: [input.contractId] }), + }); + + if (!balanceResponse.ok) { + onStatusChange?.("failed"); + throw new EscrowDisputeResolutionError(await readError(balanceResponse)); + } + + const balancePayload = await balanceResponse.json(); + const availableBalance = asBalanceValue(balancePayload); + const totalDistribution = input.distributions.reduce((sum, distribution) => sum + distribution.amount, 0); + + if (!Number.isFinite(availableBalance) || availableBalance <= 0) { + onStatusChange?.("failed"); + throw new EscrowDisputeResolutionError("The escrow balance could not be determined"); + } + + if (Math.abs(totalDistribution - availableBalance) > 1e-8) { + onStatusChange?.("failed"); + throw new EscrowDisputeResolutionError( + `Distributions must sum to the escrow balance of ${availableBalance}` + ); + } + + const resolveResponse = await fetch("/api/escrow/single-release/v2/resolve-dispute", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + + if (!resolveResponse.ok) { + onStatusChange?.("failed"); + throw new EscrowDisputeResolutionError(await readError(resolveResponse)); + } + + const { unsignedXdr } = (await resolveResponse.json()) as ResolveDisputeResponse; + if (!unsignedXdr) { + onStatusChange?.("failed"); + throw new EscrowDisputeResolutionError("Escrow service did not return a dispute-resolution transaction"); + } + + let signedXdr: string; + try { + signedXdr = await signTransaction(unsignedXdr); + } catch (error) { + onStatusChange?.("failed"); + const message = error instanceof Error ? error.message : "Wallet signature was rejected"; + throw new EscrowDisputeResolutionError(message); + } + + if (!signedXdr) { + onStatusChange?.("failed"); + throw new EscrowDisputeResolutionError("Wallet did not return a signed transaction"); + } + + onStatusChange?.("submitting"); + const submissionResponse = await fetch("/api/stellar/send-transaction", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ signedXdr }), + }); + + if (!submissionResponse.ok) { + onStatusChange?.("failed"); + throw new EscrowDisputeResolutionError(await readError(submissionResponse)); + } + + onStatusChange?.("resolved"); +}