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
5 changes: 3 additions & 2 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,5 +128,6 @@ Goal: give logged-in users a reason to log in beyond payout eligibility.
- Default payout is email-based via linked user email.
- Agent tag `bountic-address` enables wallet-native payout path.

2. Future split suggestion (not in scope now)
- TODO: use an LLM to suggest a payout split based on diff/commit attribution.
2. Payout split suggestion
- Merged PRs can now split payouts by explicit `bountic-split` weights or by commit-author attribution when no explicit split is provided.
- Future improvement: use an LLM to propose a maintainer-editable split based on diff/commit attribution.
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,20 @@ 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:
```md
<!-- bountic-address: 0xYOURWALLET -->
```
4. **For multi-contributor PRs:** Contributors can provide per-recipient wallet tags and an optional weighted split:
```md
<!-- bountic-recipient: @alice 0xALICEWALLET -->
<!-- bountic-recipient: @bob 0xBOBWALLET -->
<!-- bountic-split:
@alice: 70
@bob: 30
-->
```
If no explicit split is provided, Bountic splits the payout by GitHub commit-author attribution on the merged PR.

### 3. Merge & Settle (Payout)
1. The maintainer merges the winning PR (`pull_request.closed`).
Expand Down Expand Up @@ -136,4 +148,4 @@ LOCUS_WEBHOOK_SECRET=
**3. Start the Development Server**
```bash
npm run dev
```
```
24 changes: 22 additions & 2 deletions app/b/[owner]/[repo]/issues/[issueNumber]/approve-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@ type Props = {
issueNumber: number;
};

function formatPayoutRecipient(recipient: {
recipientUsername: string;
amount: number;
payoutType: "wallet" | "email" | "unclaimed";
recipientEmail?: string | null;
recipientWallet?: string | null;
}) {
if (recipient.payoutType === "wallet" && recipient.recipientWallet) {
return `@${recipient.recipientUsername}: $${recipient.amount.toFixed(2)} to ${recipient.recipientWallet.slice(0, 6)}...${recipient.recipientWallet.slice(-4)}`;
}

if (recipient.payoutType === "email" && recipient.recipientEmail) {
return `@${recipient.recipientUsername}: $${recipient.amount.toFixed(2)} to ${recipient.recipientEmail}`;
}

return `@${recipient.recipientUsername}: $${recipient.amount.toFixed(2)} unclaimed`;
}

export function ApproveButton({ owner, repo, issueNumber }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
Expand All @@ -26,10 +44,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, recipientEmail, recipientWallet, payouts } = response.payout;

let message = "";
if (payoutType === "wallet" && recipientWallet) {
if (payoutType === "split" && payouts?.length) {
message = `Split payout approved: ${payouts.map(formatPayoutRecipient).join("; ")}`;
} 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
13 changes: 12 additions & 1 deletion lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,16 @@ export type FundResponse = {
checkout_url: string;
};

export type PayoutRecipient = {
recipientUsername: string;
amount: number;
transactionId: string;
txHash: string | null;
payoutType: "wallet" | "email" | "unclaimed";
recipientEmail?: string | null;
recipientWallet?: string | null;
};

export async function fetchBounties(params: {
status?: string;
min_amount?: number;
Expand Down Expand Up @@ -160,11 +170,12 @@ 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?: PayoutRecipient[];
approvedBy: string;
};
}> {
Expand Down
77 changes: 47 additions & 30 deletions lib/bounty/services/approve-payout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export async function approveBountyPayout(params: {
}

let winningPrBody: string | null = null;
let winningPrCommitAuthors: string[] = [];
if (bounty.winning_pr_number) {
try {
const installationId = await getGithubRepoInstallationId(params.owner, params.repo);
Expand All @@ -52,18 +53,29 @@ export async function approveBountyPayout(params: {
pull_number: bounty.winning_pr_number,
});
winningPrBody = prResponse.data.body ?? null;

const commitsResponse = await github.rest.pulls.listCommits({
owner: params.owner,
repo: params.repo,
pull_number: bounty.winning_pr_number,
per_page: 100,
});

winningPrCommitAuthors = commitsResponse.data
.map((commit) => commit.author?.login)
.filter((login): login is string => typeof login === "string" && login.length > 0);
} catch (err) {
console.warn("Failed to fetch PR body for wallet extraction:", err);
console.warn("Failed to fetch PR details for payout resolution:", err);
}
}

// only single payout for now, later multiple payouts
const payoutResult = await resolveAndPayout({
owner: params.owner,
repo: params.repo,
issueNumber: params.issueNumber,
winningPrAuthor: bounty.winning_pr_author,
winningPrBody,
winningPrCommitAuthors,
amount: bounty.total_amount,
issueId,
});
Expand All @@ -84,38 +96,42 @@ 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}`);
}

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,
},
});
const { error: activityError } = await supabase.from("activity_events").insert(
payoutResult.recipients.map((recipient) => ({
issue_id: issueId,
event_type: "PAYOUT_SENT" as const,
actor_username: recipient.recipientUsername,
amount: recipient.amount,
tx_hash: recipient.txHash,
metadata: {
approved_by: params.approvedBy,
payout_source: "web",
payout_type: recipient.payoutType,
},
})),
);

if (activityError) {
throw new Error(`Failed to persist payout activity: ${activityError.message}`);
Expand All @@ -132,6 +148,7 @@ export async function approveBountyPayout(params: {
recipientWallet: payoutResult.recipientWallet,
txHash: payoutResult.txHash,
transactionId: payoutResult.transactionId,
payouts: payoutResult.recipients,
approvedBy: params.approvedBy,
};
}
}
Loading