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
44 changes: 27 additions & 17 deletions lib/bounty/services/approve-payout.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

import "server-only";

import { resolveAndPayout } from "@/lib/bounty/services/payout";
Expand Down Expand Up @@ -57,7 +58,6 @@ export async function approveBountyPayout(params: {
}
}

// only single payout for now, later multiple payouts
const payoutResult = await resolveAndPayout({
owner: params.owner,
repo: params.repo,
Expand All @@ -84,21 +84,23 @@ 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,
},
});
const { error: payoutEventError } = await supabase.from("payout_events").insert(
payoutResult.recipients.map((recipient) => ({
issue_id: issueId,
recipient_username: recipient.recipientUsername,
amount: recipient.amount,
locus_transaction_id: recipient.transactionId,
transaction_hash: recipient.txHash,
status: "SUCCESS" as const,
metadata: {
approved_by: params.approvedBy,
payout_source: "web",
payout_type: recipient.payoutType,
recipient_email: recipient.recipientEmail,
recipient_wallet: recipient.recipientWallet,
},
})),
);

if (payoutEventError) {
throw new Error(`Failed to persist payout event: ${payoutEventError.message}`);
Expand All @@ -114,6 +116,13 @@ export async function approveBountyPayout(params: {
approved_by: params.approvedBy,
payout_source: "web",
payout_type: payoutResult.payoutType,
recipients: payoutResult.recipients.map((recipient) => ({
username: recipient.recipientUsername,
amount: recipient.amount,
payout_type: recipient.payoutType,
recipient_wallet: recipient.recipientWallet,
recipient_email: recipient.recipientEmail,
})),
},
});

Expand All @@ -130,8 +139,9 @@ export async function approveBountyPayout(params: {
payoutType: payoutResult.payoutType,
recipientEmail: payoutResult.recipientEmail,
recipientWallet: payoutResult.recipientWallet,
recipients: payoutResult.recipients,
txHash: payoutResult.txHash,
transactionId: payoutResult.transactionId,
approvedBy: params.approvedBy,
};
}
}
139 changes: 119 additions & 20 deletions lib/bounty/services/payout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,98 @@ import { getSupabaseServiceClient } from "@/lib/clients/supabase/server";
import { getSupabaseServerEnv } from "@/lib/env/server";
import { getGithubInstallationClient, getGithubRepoInstallationId } from "@/lib/clients/github/server";

const BOUNTIC_ADDRESS_REGEX = /<!--\s*bountic-address:\s*(0x[a-fA-F0-9]{40})\s*-->/i;
const BOUNTIC_ADDRESS_COMMENT_REGEX = /<!--\s*bountic-address:\s*([\s\S]*?)\s*-->/gi;
const EVM_ADDRESS_REGEX = /0x[a-fA-F0-9]{40}/;

export type PayoutResult = {
transactionId: string;
txHash: string | null;
payoutType: "wallet" | "email" | "unclaimed";
recipientEmail?: string | null;
recipientWallet?: string | null;
recipients: PayoutRecipientResult[];
};

function extractWalletFromPrBody(prBody: string | null): string | null {
if (!prBody) return null;
const match = BOUNTIC_ADDRESS_REGEX.exec(prBody);
return match ? match[1] : null;
export type PayoutRecipientResult = {
transactionId: string;
txHash: string | null;
payoutType: "wallet" | "email" | "unclaimed";
recipientUsername: string;
amount: number;
recipientEmail?: string | null;
recipientWallet?: string | null;
};

type WalletRecipient = {
username: string;
wallet: string;
};

function parseUsernameFromAddressTag(rawTag: string, wallet: string, fallbackUsername: string): string {
const withoutWallet = rawTag.replace(wallet, "").trim();
const usernameToken = withoutWallet
.replace(/^[=:\s-]+/, "")
.replace(/[=:\s-]+$/, "")
.match(/^@?([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,38}[a-zA-Z0-9])?)$/)?.[1];

return usernameToken || fallbackUsername;
}

function extractWalletRecipientsFromPrBody(
prBody: string | null,
fallbackUsername: string,
): WalletRecipient[] {
if (!prBody) return [];

const recipients: WalletRecipient[] = [];
const seenWallets = new Set<string>();

for (const match of prBody.matchAll(BOUNTIC_ADDRESS_COMMENT_REGEX)) {
const rawTag = match[1]?.trim() ?? "";
const walletMatch = EVM_ADDRESS_REGEX.exec(rawTag);
if (!walletMatch) continue;

const wallet = walletMatch[0];
const normalizedWallet = wallet.toLowerCase();
if (seenWallets.has(normalizedWallet)) continue;

recipients.push({
username: parseUsernameFromAddressTag(rawTag, wallet, fallbackUsername),
wallet,
});
seenWallets.add(normalizedWallet);
}

return recipients;
}

function splitAmountEvenly(amount: number, recipientCount: number): number[] {
if (recipientCount < 1) return [];

const totalCents = Math.round(amount * 100);
const baseCents = Math.floor(totalCents / recipientCount);
const remainder = totalCents % recipientCount;

return Array.from({ length: recipientCount }, (_, index) => (
(baseCents + (index < remainder ? 1 : 0)) / 100
));
}

function summarizePayouts(recipients: PayoutRecipientResult[]): PayoutResult {
const transactionIds = recipients.map((recipient) => recipient.transactionId).join(",");
const txHashes = recipients
.map((recipient) => recipient.txHash)
.filter((txHash): txHash is string => Boolean(txHash));
const firstRecipient = recipients[0];

return {
transactionId: transactionIds,
txHash: txHashes.length > 0 ? txHashes.join(",") : null,
payoutType: firstRecipient?.payoutType ?? "unclaimed",
recipientEmail: firstRecipient?.recipientEmail ?? null,
recipientWallet: firstRecipient?.recipientWallet ?? null,
recipients,
};
}

async function getRecipientEmail(githubUsername: string): Promise<string | null> {
Expand Down Expand Up @@ -52,7 +130,8 @@ export async function callLocusPayoutByEmail(params: {
toEmail: string;
amount: number;
memo: string;
}): Promise<PayoutResult> {
recipientUsername: string;
}): Promise<PayoutRecipientResult> {
const locus = getLocusServerClient();

try {
Expand All @@ -73,6 +152,8 @@ export async function callLocusPayoutByEmail(params: {
transactionId: payload.transaction_id,
txHash: payload.tx_hash ?? null,
payoutType: "email",
recipientUsername: params.recipientUsername,
amount: params.amount,
recipientEmail: params.toEmail,
};
} catch (error) {
Expand All @@ -85,7 +166,8 @@ export async function callLocusPayoutByWallet(params: {
toAddress: string;
amount: number;
memo: string;
}): Promise<PayoutResult> {
recipientUsername: string;
}): Promise<PayoutRecipientResult> {
const locus = getLocusServerClient();

const payload = await locus.request<{
Expand All @@ -104,6 +186,8 @@ export async function callLocusPayoutByWallet(params: {
transactionId: payload.transaction_id,
txHash: payload.tx_hash ?? null,
payoutType: "wallet",
recipientUsername: params.recipientUsername,
amount: params.amount,
recipientWallet: params.toAddress,
};
}
Expand All @@ -115,7 +199,7 @@ export async function handleUnclaimedPayout(params: {
winningPrAuthor: string;
amount: number;
issueId: string;
}): Promise<PayoutResult> {
}): Promise<PayoutRecipientResult> {
const env = getSupabaseServerEnv();

await commentOnIssue({
Expand All @@ -133,6 +217,8 @@ Once connected, a maintainer can approve your payout and the funds will be sent
transactionId: `unclaimed_${Date.now()}`,
txHash: null,
payoutType: "unclaimed",
recipientUsername: params.winningPrAuthor,
amount: params.amount,
recipientEmail: null,
};
}
Expand All @@ -146,31 +232,44 @@ export async function resolveAndPayout(params: {
amount: number;
issueId: string;
}): Promise<PayoutResult> {
const walletFromPr = extractWalletFromPrBody(params.winningPrBody);
const walletRecipients = extractWalletRecipientsFromPrBody(
params.winningPrBody,
params.winningPrAuthor,
);
const recipientEmail = await getRecipientEmail(params.winningPrAuthor);

if (walletFromPr) {
return callLocusPayoutByWallet({
toAddress: walletFromPr,
amount: params.amount,
memo: `Bountic payout for ${params.issueId}`,
});
if (walletRecipients.length > 0) {
const splitAmounts = splitAmountEvenly(params.amount, walletRecipients.length);
const payoutRecipients: PayoutRecipientResult[] = [];

for (const [index, recipient] of walletRecipients.entries()) {
payoutRecipients.push(await callLocusPayoutByWallet({
toAddress: recipient.wallet,
amount: splitAmounts[index],
memo: `Bountic payout for ${params.issueId}`,
recipientUsername: recipient.username,
}));
}

return summarizePayouts(payoutRecipients);
}

if (recipientEmail) {
return callLocusPayoutByEmail({
return summarizePayouts([await callLocusPayoutByEmail({
toEmail: recipientEmail,
amount: params.amount,
memo: `Bountic payout for ${params.issueId}`,
});
recipientUsername: params.winningPrAuthor,
})]);
}

return handleUnclaimedPayout({
return summarizePayouts([await handleUnclaimedPayout({
owner: params.owner,
repo: params.repo,
issueNumber: params.issueNumber,
winningPrAuthor: params.winningPrAuthor,
amount: params.amount,
issueId: params.issueId,
});
}
})]);
}