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
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<ResolveDisputeRequest> & {
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);
}
}
18 changes: 14 additions & 4 deletions p2p-safe-swap/app/escrow/[id]/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -25,24 +26,33 @@ const MOCK_ESCROW: Escrow = {
};

const MOCK_IS_ADMIN = true;
const MOCK_IS_MODERATOR = true;

interface EscrowAdminPageProps {
params: Promise<{ id: string }>;
}

export default function EscrowAdminPage({ params }: EscrowAdminPageProps) {
const { id } = use(params);
const escrow: Escrow = { ...MOCK_ESCROW, contractId: id };
const [escrow, setEscrow] = useState<Escrow>({ ...MOCK_ESCROW, contractId: id });

return (
<main className="mx-auto flex min-h-screen w-full max-w-md flex-col">
<main className="mx-auto flex min-h-screen w-full max-w-md flex-col gap-4 p-4">
<EscrowAdminUpdateForm
escrow={escrow}
isAdmin={MOCK_IS_ADMIN}
onSubmit={(payload) => {
console.log("Escrow update submitted:", payload);
}}
/>
<ResolveDisputePanel
escrow={escrow}
isModerator={MOCK_IS_MODERATOR}
currentWalletAddress="GOPQ3RSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ012345"
onResolved={(updatedEscrow) => {
setEscrow(updatedEscrow);
}}
/>
</main>
);
}
223 changes: 223 additions & 0 deletions p2p-safe-swap/frontend/components/escrow/ResolveDisputePanel.tsx
Original file line number Diff line number Diff line change
@@ -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<EscrowDistribution[]>([]);
const [newAddress, setNewAddress] = useState("");
const [newAmount, setNewAmount] = useState("");
const [status, setStatus] = useState<EscrowDisputeResolutionStatus>("idle");
const [error, setError] = useState<string | null>(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 (
<section
aria-label="Resolve dispute"
className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-sm"
>
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<ShieldCheck size={18} aria-hidden="true" />
</div>
<div className="flex flex-col gap-1">
<h2 className="text-base font-semibold text-foreground">Resolve dispute</h2>
<p className="text-sm text-muted-foreground">
Distribute the escrow balance to the resolved recipients and submit the moderator transaction.
</p>
</div>
</div>

<div className="flex flex-col gap-2 rounded-2xl border border-border/70 bg-background/60 p-3">
<label htmlFor="distribution-address" className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Recipient address
</label>
<input
id="distribution-address"
type="text"
value={newAddress}
onChange={(event) => 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"
/>

<label htmlFor="distribution-amount" className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Amount
</label>
<input
id="distribution-amount"
type="number"
inputMode="decimal"
step="any"
value={newAmount}
onChange={(event) => 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"
/>

<button
type="button"
onClick={addDistribution}
className="flex items-center justify-center gap-2 rounded-xl border border-secondary px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-primary hover:text-black"
>
<Plus size={16} aria-hidden="true" />
Add distribution
</button>
</div>

{distributions.length > 0 && (
<div className="flex flex-col gap-2">
{distributions.map((distribution, index) => (
<div key={`${distribution.address}-${index}`} className="flex items-center justify-between gap-3 rounded-xl border border-border px-3 py-2">
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-foreground">{distribution.address}</span>
<span className="text-xs text-muted-foreground tabular-nums">{distribution.amount}</span>
</div>
<button
type="button"
onClick={() => removeDistribution(index)}
aria-label={`Remove distribution ${index + 1}`}
className="rounded-full p-1.5 text-muted-foreground transition-colors hover:text-destructive"
>
<Trash2 size={16} aria-hidden="true" />
</button>
</div>
))}
</div>
)}

<div className="flex items-center justify-between rounded-xl border border-border/70 bg-background/50 px-3 py-2 text-sm">
<span className="text-muted-foreground">Total distribution</span>
<span className="font-semibold text-foreground tabular-nums">{totalDistribution.toFixed(2)}</span>
</div>

{escrow.status === "resolved" && escrow.resolutionDistributions?.length ? (
<div className="flex flex-col gap-2 rounded-xl border border-primary/30 bg-primary/10 px-3 py-2 text-sm text-primary">
<span className="font-medium">Resolved distribution</span>
{escrow.resolutionDistributions.map((distribution, index) => (
<div key={`${distribution.address}-${index}`} className="flex items-center justify-between gap-2">
<span className="truncate">{distribution.address}</span>
<span className="font-semibold tabular-nums">{distribution.amount}</span>
</div>
))}
</div>
) : null}

{error && (
<div className="flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
<span>{error}</span>
</div>
)}

<Button
variant="primary"
size="lg"
label={status === "submitting" ? "Submitting…" : "Resolve dispute"}
onClick={handleResolve}
disabled={!canSubmit}
className="w-full"
/>
</section>
);
}
Loading