Skip to content
Open
23 changes: 13 additions & 10 deletions app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,21 @@ export function ApproveButton({ owner, repo, issueNumber }: Props) {
startTransition(async () => {
try {
const response = await approveBounty({ owner, repo, issueNumber });
const { payoutType, recipientEmail, recipientWallet } = response.payout;
const results = response.payout.results;

let message = "";
if (payoutType === "wallet" && recipientWallet) {
message = `Payout sent to wallet ${recipientWallet.slice(0, 6)}...${recipientWallet.slice(-4)}`;
} else if (payoutType === "email" && recipientEmail) {
message = `Payout sent to ${recipientEmail}`;
} else if (payoutType === "unclaimed") {
message = "Winner not connected. Notified via issue comment to claim.";
}
let messages = results.map(res => {
if (res.payoutType === "failed") {
return `@${res.recipientUsername}: Payout failed. (Requires manual retry)`;
} else if (res.payoutType === "wallet" && res.recipientWallet) {
return `@${res.recipientUsername}: Sent to wallet ${res.recipientWallet.slice(0, 6)}...${res.recipientWallet.slice(-4)}`;
} else if (res.payoutType === "email" && res.recipientEmail) {
return `@${res.recipientUsername}: Sent to ${res.recipientEmail}`;
} else {
return `@${res.recipientUsername}: Not connected. Notified via comment.`;
}
});

setSuccessTxHash(message);
setSuccessTxHash(messages.join(" | "));
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to approve payout");
Expand Down
16 changes: 10 additions & 6 deletions lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,12 +159,16 @@ export async function approveBounty(params: {
payout: {
issueId: string;
amount: number;
recipient: string;
payoutType: "wallet" | "email" | "unclaimed";
recipientEmail: string | null;
recipientWallet: string | null;
txHash: string | null;
transactionId: string;
results: Array<{
transactionId: string | null;
txHash: string | null;
payoutType: "wallet" | "email" | "unclaimed" | "failed";
recipientEmail?: string | null;
recipientWallet?: string | null;
recipientUsername: string;
amount: number;
status: "SUCCESS" | "FAILED";
}>;
approvedBy: string;
};
}> {
Expand Down
85 changes: 42 additions & 43 deletions lib/bounty/services/approve-payout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ export async function approveBountyPayout(params: {
}
}

// only single payout for now, later multiple payouts
const payoutResult = await resolveAndPayout({
// support multiple payouts
const payoutResults = await resolveAndPayout({
owner: params.owner,
repo: params.repo,
issueNumber: params.issueNumber,
Expand All @@ -69,12 +69,14 @@ export async function approveBountyPayout(params: {
});

const now = new Date().toISOString();
// Use the first transaction hash for the main bounty record as a reference
const primaryTxHash = payoutResults.find(r => r.txHash)?.txHash ?? payoutResults[0].txHash;

const { error: updateError } = await supabase
.from("bounties")
.update({
status: "PAID",
payout_tx_hash: payoutResult.txHash,
payout_tx_hash: primaryTxHash,
paid_at: now,
approved_by: params.approvedBy,
})
Expand All @@ -84,54 +86,51 @@ export async function approveBountyPayout(params: {
throw new Error(`Failed to update bounty status to PAID: ${updateError.message}`);
}

const { error: payoutEventError } = await supabase.from("payout_events").insert({
issue_id: issueId,
recipient_username: bounty.winning_pr_author,
amount: bounty.total_amount,
locus_transaction_id: payoutResult.transactionId,
transaction_hash: payoutResult.txHash,
status: "SUCCESS",
metadata: {
approved_by: params.approvedBy,
payout_source: "web",
payout_type: payoutResult.payoutType,
recipient_email: payoutResult.recipientEmail,
recipient_wallet: payoutResult.recipientWallet,
},
});

if (payoutEventError) {
throw new Error(`Failed to persist payout event: ${payoutEventError.message}`);
}

const { error: activityError } = await supabase.from("activity_events").insert({
issue_id: issueId,
event_type: "PAYOUT_SENT",
actor_username: bounty.winning_pr_author,
amount: bounty.total_amount,
tx_hash: payoutResult.txHash,
metadata: {
approved_by: params.approvedBy,
payout_source: "web",
payout_type: payoutResult.payoutType,
},
});
for (const res of payoutResults) {
const { error: payoutEventError } = await supabase.from("payout_events").insert({
issue_id: issueId,
recipient_username: res.recipientUsername,
amount: res.amount,
locus_transaction_id: res.transactionId,
transaction_hash: res.txHash,
status: res.status,
metadata: {
approved_by: params.approvedBy,
payout_source: "web",
payout_type: res.payoutType,
recipient_email: res.recipientEmail,
recipient_wallet: res.recipientWallet,
},
});

if (payoutEventError) {
console.error(`Failed to persist payout event for ${res.recipientUsername}:`, payoutEventError);
}

if (activityError) {
throw new Error(`Failed to persist payout activity: ${activityError.message}`);
const { error: activityError } = await supabase.from("activity_events").insert({
issue_id: issueId,
event_type: "PAYOUT_SENT",
actor_username: res.recipientUsername,
amount: res.amount,
tx_hash: res.txHash,
metadata: {
approved_by: params.approvedBy,
payout_source: "web",
payout_type: res.payoutType,
},
});

if (activityError) {
console.error(`Failed to persist payout activity for ${res.recipientUsername}:`, activityError);
}
}

await syncGithubBountyArtifacts(issueId);

return {
issueId,
amount: bounty.total_amount,
recipient: bounty.winning_pr_author,
payoutType: payoutResult.payoutType,
recipientEmail: payoutResult.recipientEmail,
recipientWallet: payoutResult.recipientWallet,
txHash: payoutResult.txHash,
transactionId: payoutResult.transactionId,
results: payoutResults,
approvedBy: params.approvedBy,
};
}
76 changes: 76 additions & 0 deletions lib/bounty/services/payout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { resolveAndPayout, PayoutResult } from "./payout";
import { getLocusServerClient } from "@/lib/clients/locus/server";
import { getSupabaseServiceClient } from "@/lib/clients/supabase/server";

// Mock dependencies
jest.mock("@/lib/clients/locus/server");
jest.mock("@/lib/clients/supabase/server");
jest.mock("@/lib/clients/github/server", () => ({
getGithubInstallationClient: jest.fn(),
getGithubRepoInstallationId: jest.fn(),
}));

describe("Multi-recipient Payout Logic", () => {
const mockLocus = {
request: jest.fn(),
};

const mockSupabase = {
from: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
eq: jest.fn().mockReturnThis(),
maybeSingle: jest.fn(),
};

beforeEach(() => {
jest.clearAllMocks();
(getLocusServerClient as jest.Mock).mockReturnValue(mockLocus);
(getSupabaseServiceClient as jest.Mock).mockReturnValue(mockSupabase);
});

test("should distribute payout evenly with remainder to the last person", async () => {
mockSupabase.maybeSingle.mockResolvedValue({ data: { email: "test@example.com" } });
mockLocus.request.mockResolvedValue({ transaction_id: "tx123" });

const results = await resolveAndPayout({
owner: "owner",
repo: "repo",
issueNumber: 1,
winningPrAuthor: "alice",
winningPrBody: "<!-- bountic-split: @a:1, @b:1, @c:1 -->",
amount: 10.00,
issueId: "owner/repo#1",
});

expect(results).toHaveLength(3);
expect(results[0].amount).toBe(3.33);
expect(results[1].amount).toBe(3.33);
expect(results[2].amount).toBe(3.34);
expect(results.every(r => r.status === "SUCCESS")).toBe(true);
});

test("should handle individual failures and record FAILED status (Double-Spend prevention)", async () => {
mockSupabase.maybeSingle.mockResolvedValue({ data: { email: "test@example.com" } });

// First call succeeds, second fails
mockLocus.request
.mockResolvedValueOnce({ transaction_id: "tx1" })
.mockRejectedValueOnce(new Error("API Timeout"))
.mockResolvedValueOnce({ transaction_id: "tx3" });

const results = await resolveAndPayout({
owner: "owner",
repo: "repo",
issueNumber: 1,
winningPrAuthor: "alice",
winningPrBody: "<!-- bountic-split: @a:50, @b:50 -->",
amount: 100.00,
issueId: "owner/repo#1",
});

expect(results).toHaveLength(2);
expect(results[0].status).toBe("SUCCESS");
expect(results[1].status).toBe("FAILED");
expect(results[1].transactionId).toBeNull();
});
});
Loading