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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,16 @@ This hackathon by Locus provided the exact infrastructure to solve this. So, I b
### 2. The Solution (Human & Machine)
1. Contributors search for open bounties.
2. They open a PR linking the issue (e.g., `Fixes #123`, `Closes #123`). Bountic detects `pull_request.opened` and marks the PR as competing.
3. **For AI Agents / Web3 Users:** Contributors embed their Locus wallet address directly in the PR markdown using a hidden comment:
``
3. **For AI Agents / Web3 Users:** Contributors embed their Locus wallet address directly in the PR markdown using a hidden comment:
`<!-- bountic-address: 0xYOURWALLET -->`
Multi-contributor PRs can provide separate wallets with named tags:
`<!-- bountic-address: github-username=0xYOURWALLET -->`

### 3. Merge & Settle (Payout)
1. The maintainer merges the winning PR (`pull_request.closed`).
2. The Bountic escrow locks. The bot posts a final status update on the issue.
3. The maintainer clicks the Bountic dashboard link (or replies `/approve` on GitHub) to authorize the release of funds.
4. Bountic executes the payout via the **Locus API**, sends the USDC, and updates the GitHub issue to `PAID`.
4. Bountic executes the payout via the **Locus API**, splits USDC evenly across unique PR commit authors when multiple contributors are present, and updates the GitHub issue to `PAID`.

---

Expand Down Expand Up @@ -136,4 +138,4 @@ LOCUS_WEBHOOK_SECRET=
**3. Start the Development Server**
```bash
npm run dev
```
```
6 changes: 4 additions & 2 deletions app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ export function ApproveButton({ owner, repo, issueNumber }: Props) {
startTransition(async () => {
try {
const response = await approveBounty({ owner, repo, issueNumber });
const { payoutType, recipientEmail, recipientWallet } = response.payout;
const { payoutType, payouts, recipientEmail, recipientWallet } = response.payout;

let message = "";
if (payoutType === "wallet" && recipientWallet) {
if (payoutType === "split" && payouts?.length) {
message = `Payout split across ${payouts.length} contributors.`;
} else 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}`;
Expand Down
11 changes: 10 additions & 1 deletion lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,20 @@ export async function approveBounty(params: {
issueId: string;
amount: number;
recipient: string;
payoutType: "wallet" | "email" | "unclaimed";
payoutType: "wallet" | "email" | "unclaimed" | "split";
recipientEmail: string | null;
recipientWallet: string | null;
txHash: string | null;
transactionId: string;
payouts?: Array<{
recipientUsername: string;
amount: number;
payoutType: "wallet" | "email" | "unclaimed";
recipientEmail?: string | null;
recipientWallet?: string | null;
txHash: string | null;
transactionId: string;
}>;
approvedBy: string;
};
}> {
Expand Down
148 changes: 113 additions & 35 deletions lib/bounty/services/approve-payout.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,67 @@
import "server-only";

import { resolveAndPayout } from "@/lib/bounty/services/payout";
import { resolveAndPayoutMany } from "@/lib/bounty/services/payout";
import { syncGithubBountyArtifacts } from "@/lib/bounty/services/github-sync";
import { getSupabaseServiceClient } from "@/lib/clients/supabase/server";
import { getGithubInstallationClient, getGithubRepoInstallationId } from "@/lib/clients/github/server";
import { buildIssueId } from "@/lib/bounty/issue-id";

type GithubInstallationClient = Awaited<ReturnType<typeof getGithubInstallationClient>>;

function splitAmountEvenly(amount: number, recipientUsernames: string[]) {
const totalCents = Math.round(amount * 100);
const baseCents = Math.floor(totalCents / recipientUsernames.length);
let remainderCents = totalCents - baseCents * recipientUsernames.length;

return recipientUsernames.map((username) => {
const cents = baseCents + (remainderCents > 0 ? 1 : 0);
remainderCents -= 1;

return {
username,
amount: cents / 100,
};
});
}

async function getMergedPrPayoutContext(params: {
github: GithubInstallationClient;
owner: string;
repo: string;
pullNumber: number;
fallbackAuthor: string;
}) {
const prResponse = await params.github.rest.pulls.get({
owner: params.owner,
repo: params.repo,
pull_number: params.pullNumber,
});

const commits = await params.github.paginate(params.github.rest.pulls.listCommits, {
owner: params.owner,
repo: params.repo,
pull_number: params.pullNumber,
per_page: 100,
});

const contributors = new Set<string>();
for (const commit of commits) {
const login = commit.author?.login;
if (login) {
contributors.add(login);
}
}

if (contributors.size === 0) {
contributors.add(params.fallbackAuthor);
}

return {
body: prResponse.data.body ?? null,
contributors: [...contributors],
};
}

export async function approveBountyPayout(params: {
owner: string;
repo: string;
Expand Down Expand Up @@ -42,39 +98,48 @@ export async function approveBountyPayout(params: {
}

let winningPrBody: string | null = null;
let payoutRecipients = splitAmountEvenly(bounty.total_amount, [bounty.winning_pr_author]);

if (bounty.winning_pr_number) {
try {
const installationId = await getGithubRepoInstallationId(params.owner, params.repo);
const github = await getGithubInstallationClient(installationId);
const prResponse = await github.rest.pulls.get({
const prContext = await getMergedPrPayoutContext({
github,
owner: params.owner,
repo: params.repo,
pull_number: bounty.winning_pr_number,
pullNumber: bounty.winning_pr_number,
fallbackAuthor: bounty.winning_pr_author,
});
winningPrBody = prResponse.data.body ?? null;
winningPrBody = prContext.body;
payoutRecipients = splitAmountEvenly(bounty.total_amount, prContext.contributors);
} catch (err) {
console.warn("Failed to fetch PR body for wallet extraction:", err);
console.warn("Failed to fetch PR payout context; falling back to PR author:", err);
}
}

// only single payout for now, later multiple payouts
const payoutResult = await resolveAndPayout({
const payoutResults = await resolveAndPayoutMany({
owner: params.owner,
repo: params.repo,
issueNumber: params.issueNumber,
winningPrAuthor: bounty.winning_pr_author,
winningPrBody,
amount: bounty.total_amount,
recipients: payoutRecipients,
issueId,
});
if (payoutResults.length === 0) {
throw new Error("No payout recipients were resolved");
}

const payoutTxHashes = payoutResults.map((payout) => payout.txHash).filter((txHash): txHash is string => Boolean(txHash));
const primaryPayout = payoutResults[0];

const now = new Date().toISOString();

const { error: updateError } = await supabase
.from("bounties")
.update({
status: "PAID",
payout_tx_hash: payoutResult.txHash,
payout_tx_hash: payoutTxHashes[0] ?? null,
paid_at: now,
approved_by: params.approvedBy,
})
Expand All @@ -84,21 +149,24 @@ 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(
payoutResults.map((payout) => ({
issue_id: issueId,
recipient_username: payout.recipientUsername,
amount: payout.amount,
locus_transaction_id: payout.transactionId,
transaction_hash: payout.txHash,
status: "SUCCESS" as const,
metadata: {
approved_by: params.approvedBy,
payout_source: "web",
payout_type: payout.payoutType,
recipient_email: payout.recipientEmail,
recipient_wallet: payout.recipientWallet,
split_count: payoutResults.length,
},
})),
);

if (payoutEventError) {
throw new Error(`Failed to persist payout event: ${payoutEventError.message}`);
Expand All @@ -107,13 +175,22 @@ export async function approveBountyPayout(params: {
const { error: activityError } = await supabase.from("activity_events").insert({
issue_id: issueId,
event_type: "PAYOUT_SENT",
actor_username: bounty.winning_pr_author,
actor_username: params.approvedBy,
amount: bounty.total_amount,
tx_hash: payoutResult.txHash,
tx_hash: payoutTxHashes[0] ?? null,
metadata: {
approved_by: params.approvedBy,
payout_source: "web",
payout_type: payoutResult.payoutType,
payout_type: payoutResults.length === 1 ? primaryPayout.payoutType : "split",
payouts: payoutResults.map((payout) => ({
recipient: payout.recipientUsername,
amount: payout.amount,
payout_type: payout.payoutType,
recipient_email: payout.recipientEmail,
recipient_wallet: payout.recipientWallet,
tx_hash: payout.txHash,
transaction_id: payout.transactionId,
})),
},
});

Expand All @@ -126,12 +203,13 @@ export async function approveBountyPayout(params: {
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,
recipient: payoutResults.length === 1 ? primaryPayout.recipientUsername : `${payoutResults.length} contributors`,
payoutType: payoutResults.length === 1 ? primaryPayout.payoutType : "split",
recipientEmail: payoutResults.length === 1 ? primaryPayout.recipientEmail : null,
recipientWallet: payoutResults.length === 1 ? primaryPayout.recipientWallet : null,
txHash: payoutTxHashes[0] ?? null,
transactionId: payoutResults.length === 1 ? primaryPayout.transactionId : `split_${Date.now()}`,
payouts: payoutResults,
approvedBy: params.approvedBy,
};
}
}
70 changes: 67 additions & 3 deletions lib/bounty/services/payout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ 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_NAMED_ADDRESS_REGEX =
/<!--\s*bountic-address:\s*@?([a-zA-Z0-9-]+)\s*(?:=|:)\s*(0x[a-fA-F0-9]{40})\s*-->/gi;

export type PayoutResult = {
transactionId: string;
Expand All @@ -15,8 +17,29 @@ export type PayoutResult = {
recipientWallet?: string | null;
};

function extractWalletFromPrBody(prBody: string | null): string | null {
export type RecipientPayoutResult = PayoutResult & {
recipientUsername: string;
amount: number;
};

function extractWalletFromPrBody(
prBody: string | null,
githubUsername: string,
allowGenericWallet: boolean,
): string | null {
if (!prBody) return null;

for (const match of prBody.matchAll(BOUNTIC_NAMED_ADDRESS_REGEX)) {
const [, matchedUsername, walletAddress] = match;
if (matchedUsername.toLowerCase() === githubUsername.toLowerCase()) {
return walletAddress;
}
}

if (!allowGenericWallet) {
return null;
}

const match = BOUNTIC_ADDRESS_REGEX.exec(prBody);
return match ? match[1] : null;
}
Expand Down Expand Up @@ -145,8 +168,13 @@ export async function resolveAndPayout(params: {
winningPrBody: string | null;
amount: number;
issueId: string;
allowGenericWallet?: boolean;
}): Promise<PayoutResult> {
const walletFromPr = extractWalletFromPrBody(params.winningPrBody);
const walletFromPr = extractWalletFromPrBody(
params.winningPrBody,
params.winningPrAuthor,
params.allowGenericWallet ?? true,
);
const recipientEmail = await getRecipientEmail(params.winningPrAuthor);

if (walletFromPr) {
Expand All @@ -173,4 +201,40 @@ export async function resolveAndPayout(params: {
amount: params.amount,
issueId: params.issueId,
});
}
}

export async function resolveAndPayoutMany(params: {
owner: string;
repo: string;
issueNumber: number;
issueId: string;
winningPrBody: string | null;
recipients: Array<{
username: string;
amount: number;
}>;
}): Promise<RecipientPayoutResult[]> {
const allowGenericWallet = params.recipients.length === 1;
const payouts: RecipientPayoutResult[] = [];

for (const recipient of params.recipients) {
const payout = await resolveAndPayout({
owner: params.owner,
repo: params.repo,
issueNumber: params.issueNumber,
issueId: params.issueId,
winningPrAuthor: recipient.username,
winningPrBody: params.winningPrBody,
amount: recipient.amount,
allowGenericWallet,
});

payouts.push({
...payout,
recipientUsername: recipient.username,
amount: recipient.amount,
});
}

return payouts;
}
Loading