Cancellation Reason
diff --git a/hooks/__tests__/use-cancel-bounty.test.ts b/hooks/__tests__/use-cancel-bounty.test.ts
index 8d851cba..db7291c3 100644
--- a/hooks/__tests__/use-cancel-bounty.test.ts
+++ b/hooks/__tests__/use-cancel-bounty.test.ts
@@ -52,10 +52,10 @@ describe("EscrowService - Cancellation & Refund", () => {
});
describe("refundAll", () => {
- it("should refund full amount for a partially released pool", async () => {
+ it("should refund remaining amount for a partially released pool", async () => {
const result = await EscrowService.refundAll("2");
- expect(result.refundedAmount).toBe(300); // totalAmount
+ expect(result.refundedAmount).toBe(150); // totalAmount - releasedAmount
expect(result.asset).toBe("USDC");
expect(result.status).toBe("completed");
expect(result.transactionHash).toHaveLength(64);
diff --git a/hooks/use-bounty-mutations.ts b/hooks/use-bounty-mutations.ts
index d40eee7f..5319c38c 100644
--- a/hooks/use-bounty-mutations.ts
+++ b/hooks/use-bounty-mutations.ts
@@ -298,7 +298,7 @@ export function useCancelBounty() {
options?: UpdateBountyMutateOptions,
) =>
mutation.mutate(
- { input: { id, status: "CANCELLED" } },
+ { input: { id, status: "CANCELLED", reason } as any },
options,
),
cancelAsync: (
@@ -306,7 +306,7 @@ export function useCancelBounty() {
options?: UpdateBountyMutateOptions,
) =>
mutation.mutateAsync(
- { input: { id, status: "CANCELLED" } },
+ { input: { id, status: "CANCELLED", reason } as any },
options,
),
};
diff --git a/hooks/use-cancel-bounty-dialog.ts b/hooks/use-cancel-bounty-dialog.ts
new file mode 100644
index 00000000..638c133d
--- /dev/null
+++ b/hooks/use-cancel-bounty-dialog.ts
@@ -0,0 +1,64 @@
+import { useState } from "react";
+import { toast } from "sonner";
+import { useCancelBounty } from "@/hooks/use-bounty-mutations";
+import { EscrowService } from "@/lib/services/escrow";
+import { authClient } from "@/lib/auth-client";
+import type { CancellationRecord } from "@/types/escrow";
+
+export function useCancelBountyDialog(
+ bountyId: string,
+ onCancelled?: (record: CancellationRecord) => void,
+) {
+ const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
+ const [cancelReason, setCancelReason] = useState("");
+ const [isCancelling, setIsCancelling] = useState(false);
+
+ const { data: session } = authClient.useSession();
+ const cancelBountyMutation = useCancelBounty();
+
+ const handleCancel = async () => {
+ if (!cancelReason.trim()) {
+ toast.error("Please provide a reason for cancellation");
+ return;
+ }
+
+ setIsCancelling(true);
+ try {
+ // 1. Update bounty status via GraphQL first
+ await cancelBountyMutation.cancelAsync({
+ id: bountyId,
+ reason: cancelReason.trim(),
+ });
+
+ // 2. Trigger escrow refund (simulates on-chain call)
+ const record = await EscrowService.cancelBounty(
+ bountyId,
+ session?.user?.id ?? "",
+ cancelReason.trim(),
+ );
+
+ toast.success("Bounty cancelled and refund initiated", {
+ description: `${record.refund?.refundedAmount ?? 0} ${record.refund?.asset ?? ""} refunded`,
+ });
+
+ onCancelled?.(record);
+ setCancelDialogOpen(false);
+ setCancelReason("");
+ } catch (err) {
+ toast.error(
+ err instanceof Error ? err.message : "Failed to cancel bounty",
+ );
+ } finally {
+ setIsCancelling(false);
+ }
+ };
+
+ return {
+ cancelDialogOpen,
+ setCancelDialogOpen,
+ cancelReason,
+ setCancelReason,
+ isCancelling,
+ handleCancel,
+ };
+}
diff --git a/lib/services/escrow.ts b/lib/services/escrow.ts
index 42fa1bb3..e20258af 100644
--- a/lib/services/escrow.ts
+++ b/lib/services/escrow.ts
@@ -204,12 +204,11 @@ export class EscrowService {
}
const txHash = this.generateMockTxHash();
- const refundedAmount = pool.totalAmount;
+ const refundedAmount = pool.totalAmount - pool.releasedAmount;
this.pools[poolId] = {
...pool,
isLocked: false,
- releasedAmount: 0,
status: "Refunded",
};
From 16a682598a21387204bb315031465ebb1886634f Mon Sep 17 00:00:00 2001
From: codebestia
Date: Mon, 30 Mar 2026 14:08:46 +0100
Subject: [PATCH 3/3] feat: implement coderabbit reviews
---
.../bounty-detail/bounty-detail-client.tsx | 2 +-
components/bounty/refund-status.tsx | 7 ++-
hooks/__tests__/use-cancel-bounty.test.ts | 13 ++--
hooks/use-bounty-mutations.ts | 30 +++++-----
hooks/use-cancel-bounty-dialog.ts | 25 +++++---
lib/services/escrow.ts | 59 +++++++++++++++++++
6 files changed, 106 insertions(+), 30 deletions(-)
diff --git a/components/bounty-detail/bounty-detail-client.tsx b/components/bounty-detail/bounty-detail-client.tsx
index eb4cfe46..b98afd56 100644
--- a/components/bounty-detail/bounty-detail-client.tsx
+++ b/components/bounty-detail/bounty-detail-client.tsx
@@ -86,7 +86,7 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) {
- {pool &&
}
+ {!isCancelled && pool &&
}
setCopiedHash(false), 2000);
- } catch {
- // clipboard write failed
+ } catch (err) {
+ console.error("Failed to copy hash:", err);
+ toast.error("Failed to copy transaction hash");
+ setCopiedHash(false);
}
};
diff --git a/hooks/__tests__/use-cancel-bounty.test.ts b/hooks/__tests__/use-cancel-bounty.test.ts
index db7291c3..aa0b25aa 100644
--- a/hooks/__tests__/use-cancel-bounty.test.ts
+++ b/hooks/__tests__/use-cancel-bounty.test.ts
@@ -1,9 +1,9 @@
import { EscrowService } from "@/lib/services/escrow";
describe("EscrowService - Cancellation & Refund", () => {
- // Reset the static mock state between tests by re-importing
- // EscrowService is a static singleton, so tests may have side effects
- // on each other for the cancel/refund methods. We test in a careful order.
+ beforeEach(() => {
+ EscrowService.__resetForTesting();
+ });
describe("cancelBounty", () => {
it("should cancel a bounty and return a full refund for Escrowed pools", async () => {
@@ -26,15 +26,16 @@ describe("EscrowService - Cancellation & Refund", () => {
});
it("should update pool status to Refunded after cancellation", async () => {
- // Pool 1 was already cancelled above, verify state
+ await EscrowService.cancelBounty("1", "user-123", "Reason");
const pool = await EscrowService.getPool("1");
expect(pool?.status).toBe("Refunded");
expect(pool?.isLocked).toBe(false);
});
it("should throw when cancelling an already refunded pool", async () => {
+ await EscrowService.cancelBounty("1", "user-123", "First");
await expect(
- EscrowService.cancelBounty("1", "user-123", "test"),
+ EscrowService.cancelBounty("1", "user-123", "Second"),
).rejects.toThrow("already been refunded");
});
@@ -62,6 +63,7 @@ describe("EscrowService - Cancellation & Refund", () => {
});
it("should throw for already refunded pool", async () => {
+ await EscrowService.refundAll("2");
await expect(EscrowService.refundAll("2")).rejects.toThrow(
"already been refunded",
);
@@ -84,6 +86,7 @@ describe("EscrowService - Cancellation & Refund", () => {
describe("getCancellation", () => {
it("should return cancellation record for cancelled bounty", async () => {
+ await EscrowService.cancelBounty("1", "user-123", "Requirements changed");
const record = await EscrowService.getCancellation("1");
expect(record).not.toBeNull();
expect(record!.bountyId).toBe("1");
diff --git a/hooks/use-bounty-mutations.ts b/hooks/use-bounty-mutations.ts
index 5319c38c..2c90b387 100644
--- a/hooks/use-bounty-mutations.ts
+++ b/hooks/use-bounty-mutations.ts
@@ -242,14 +242,18 @@ export function useClaimBounty() {
}
/**
- * Hook to cancel a bounty and trigger escrow refund
- * Calls EscrowService.cancelBounty which simulates on-chain
- * BountyRegistry.cancel_bounty() + CoreEscrow.refund_all()
+ * Hook to cancel a bounty and update its status in the registry.
+ * This hook handles the GraphQL status update to "CANCELLED".
+ * Escrow cancellation/refund is handled separately via EscrowService.cancelBounty.
+ *
+ * @returns Mutation object with:
+ * - cancel: (id: string, reason?: string) => void
+ * - cancelAsync: (id: string, reason?: string) => Promise
+ * - isPending: boolean (standard TanStack Query state)
*
- * @returns Mutation object with cancel method and refund result state
* @example
- * const { cancel, isPending, refundResult } = useCancelBounty();
- * cancel({ bountyId: "123", reason: "No longer needed" });
+ * const { cancel, isPending } = useCancelBounty();
+ * cancel({ id: "123", reason: "Budget changed" });
*/
export function useCancelBounty() {
const queryClient = useQueryClient();
@@ -297,18 +301,16 @@ export function useCancelBounty() {
{ id, reason }: { id: string; reason?: string },
options?: UpdateBountyMutateOptions,
) =>
- mutation.mutate(
- { input: { id, status: "CANCELLED", reason } as any },
- options,
- ),
+ // 'reason' is intentionally ignored in the GraphQL mutation because
+ // UpdateBountyInput does not support it. It is consumed by EscrowService.cancelBounty.
+ mutation.mutate({ input: { id, status: "CANCELLED" } }, options),
cancelAsync: (
{ id, reason }: { id: string; reason?: string },
options?: UpdateBountyMutateOptions,
) =>
- mutation.mutateAsync(
- { input: { id, status: "CANCELLED", reason } as any },
- options,
- ),
+ // 'reason' is intentionally ignored in the GraphQL mutation because
+ // UpdateBountyInput does not support it. It is consumed by EscrowService.cancelBounty.
+ mutation.mutateAsync({ input: { id, status: "CANCELLED" } }, options),
};
}
diff --git a/hooks/use-cancel-bounty-dialog.ts b/hooks/use-cancel-bounty-dialog.ts
index 638c133d..4f7b92f2 100644
--- a/hooks/use-cancel-bounty-dialog.ts
+++ b/hooks/use-cancel-bounty-dialog.ts
@@ -23,20 +23,29 @@ export function useCancelBountyDialog(
}
setIsCancelling(true);
- try {
- // 1. Update bounty status via GraphQL first
- await cancelBountyMutation.cancelAsync({
- id: bountyId,
- reason: cancelReason.trim(),
- });
+ let record: CancellationRecord | null = null;
- // 2. Trigger escrow refund (simulates on-chain call)
- const record = await EscrowService.cancelBounty(
+ try {
+ // 1. Trigger escrow refund first (simulates on-chain call)
+ record = await EscrowService.cancelBounty(
bountyId,
session?.user?.id ?? "",
cancelReason.trim(),
);
+ // 2. Update bounty status via GraphQL
+ // If this fails, we must revert the escrow state
+ try {
+ await cancelBountyMutation.cancelAsync({
+ id: bountyId,
+ reason: cancelReason.trim(),
+ });
+ } catch (mutationErr) {
+ console.error("GraphQL mutation failed, reverting escrow:", mutationErr);
+ await EscrowService.revertCancel(bountyId);
+ throw mutationErr;
+ }
+
toast.success("Bounty cancelled and refund initiated", {
description: `${record.refund?.refundedAmount ?? 0} ${record.refund?.asset ?? ""} refunded`,
});
diff --git a/lib/services/escrow.ts b/lib/services/escrow.ts
index e20258af..b1ec5c5c 100644
--- a/lib/services/escrow.ts
+++ b/lib/services/escrow.ts
@@ -76,6 +76,44 @@ export class EscrowService {
private static cancellations: Record = {};
+ /**
+ * TEST-ONLY: Resets static properties to initial state to ensure tests
+ * do not bleed into each other due to static mutation.
+ */
+ static __resetForTesting() {
+ this.pools = {
+ "1": {
+ poolId: "1",
+ totalAmount: 500,
+ asset: "USDC",
+ isLocked: true,
+ expiry: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
+ releasedAmount: 0,
+ status: "Escrowed",
+ },
+ "2": {
+ poolId: "2",
+ totalAmount: 300,
+ asset: "USDC",
+ isLocked: true,
+ expiry: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(),
+ releasedAmount: 150,
+ status: "Partially Released",
+ },
+ "3": {
+ poolId: "3",
+ totalAmount: 200,
+ asset: "USDC",
+ isLocked: false,
+ expiry: null,
+ releasedAmount: 200,
+ status: "Fully Released",
+ },
+ };
+ // Note: slots are not modified by cancellation logic currently
+ this.cancellations = {};
+ }
+
/**
* Get the escrow pool details for a given pool ID (usually bounty ID in our mock).
*/
@@ -187,6 +225,27 @@ export class EscrowService {
return record;
}
+ /**
+ * Reverts a cancellation by restoring the pool to its previous state.
+ */
+ static async revertCancel(bountyId: string): Promise {
+ await this.simulateDelay(400);
+
+ const pool = this.pools[bountyId];
+ if (!pool) return;
+
+ // Restore status based on existing releasedAmount
+ const status = pool.releasedAmount > 0 ? "Partially Released" : "Escrowed";
+
+ this.pools[bountyId] = {
+ ...pool,
+ isLocked: true,
+ status,
+ };
+
+ delete this.cancellations[bountyId];
+ }
+
/**
* Refund all funds in an escrow pool.
* Maps to CoreEscrow.refund_all(pool_id) on-chain.